From 4a5b76785d29bc8aafd0dd5a262ec55ae88f3d74 Mon Sep 17 00:00:00 2001 From: Sanath <87820868+sansynx@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:30:56 +0000 Subject: [PATCH] fix: expose write-only field metadata --- packages/forge/README.md | 9 +++-- packages/forge/plugin/forge.wit | 4 +- packages/forge/src/index.ts | 8 +++- packages/forge/test/fixtures/metadata-host.js | 20 ++++++++++ packages/forge/test/plugin.test.js | 25 ++++++++++++- src/link.rs | 21 +++++++---- src/link/openapi.rs | 4 ++ src/plugin/wasm.rs | 5 ++- src/types.rs | 21 +++++++++++ tests/parse/requests.rs | 24 +++++++++++- tests/parse/types/objects.rs | 36 +++++++++++++++++- tests/parse/types/refs.rs | 37 +++++++++++++++++++ tests/wasm.rs | 5 ++- tests/wasm/dump.rs | 3 +- tests/wasm/ts/src/lib.rs | 6 +++ tests/wasm/ts/wit/forge.wit | 4 +- 16 files changed, 209 insertions(+), 23 deletions(-) diff --git a/packages/forge/README.md b/packages/forge/README.md index 39bc2163f..932805144 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -84,9 +84,12 @@ Array types expose `minItems` and `maxItems`; an absent bound is can handle it explicitly. Other unrecognized string formats remain `scalar: 'string'`. Forge does not validate request values. -The plugin interface is `forge:plugin@0.3.0`. Rebuild plugins compiled -against `0.2.0` with the updated package before running them with this -version of Forge. +Fields expose `writeOnly` so generators can omit those properties from +responses while keeping them in requests, including required properties. + +The plugin interface is `forge:plugin@0.4.0`. Rebuild plugins compiled +against earlier interface versions with the updated package before running +them with this version of Forge. A plugin fails by throwing; forge stops and prints the message. Its files go through the same checks as forge's own: a path must be relative, use `/`, diff --git a/packages/forge/plugin/forge.wit b/packages/forge/plugin/forge.wit index af93aa10e..216cc2251 100644 --- a/packages/forge/plugin/forge.wit +++ b/packages/forge/plugin/forge.wit @@ -1,5 +1,5 @@ -package forge:plugin@0.3.0; +package forge:plugin@0.4.0; /// Read-only access to the forge a plugin runs on, which the host keeps. /// Each id is an index the host gave out. An id the host never gave out @@ -146,6 +146,8 @@ interface forge { deprecated: bool, /// Whether the server sets the property, so a client doesn't send it. read-only: bool, + /// Whether the client sends the property but responses omit it. + write-only: bool, /// Whether the value is a secret, such as a password or a token. sensitive: bool, /// The `default` of the property's schema. diff --git a/packages/forge/src/index.ts b/packages/forge/src/index.ts index 9b374eab9..baa51b023 100644 --- a/packages/forge/src/index.ts +++ b/packages/forge/src/index.ts @@ -32,8 +32,8 @@ // classes behind them stay private, so the bindings never show in // index.d.ts. -import * as host from 'forge:plugin/forge@0.3.0'; -import type * as hostPlugin from 'forge:plugin/plugin@0.3.0'; +import * as host from 'forge:plugin/forge@0.4.0'; +import type * as hostPlugin from 'forge:plugin/plugin@0.4.0'; // --------------------------------------------------------------------------- // The API. @@ -267,6 +267,8 @@ export interface Field { * though the same model's field appears in responses: `readOnly`. */ readonly readOnly: boolean; + /** Whether the client sends the property but responses omit it: `writeOnly`. */ + readonly writeOnly: boolean; /** Whether the value is a secret, such as a password or a token, to keep out of logs: `x-sensitive`. */ readonly sensitive: boolean; /** The `default` of the property's schema, or undefined if it has none. */ @@ -927,6 +929,7 @@ class FieldImpl implements Field { readonly description: string; readonly deprecated: boolean; readonly readOnly: boolean; + readonly writeOnly: boolean; readonly sensitive: boolean; readonly default: Json | undefined; private readonly ty: host.TypeId; @@ -944,6 +947,7 @@ class FieldImpl implements Field { this.description = info.description; this.deprecated = info.deprecated; this.readOnly = info.readOnly; + this.writeOnly = info.writeOnly; this.sensitive = info.sensitive; const value = opt(info.default); this.default = value === undefined ? undefined : parseJson(value); diff --git a/packages/forge/test/fixtures/metadata-host.js b/packages/forge/test/fixtures/metadata-host.js index f9a9b546e..c1347c65e 100644 --- a/packages/forge/test/fixtures/metadata-host.js +++ b/packages/forge/test/fixtures/metadata-host.js @@ -12,6 +12,26 @@ const types = [ { tag: 'scalar', val: { tag: 'number', val: 'int32' } }, ]; +const database = { + fields: [ + { wireName: 'host', writeOnly: false }, + { wireName: 'password', writeOnly: true }, + ].map((field) => ({ + name: [field.wireName], + required: true, + ty: 3, + description: '', + deprecated: false, + readOnly: false, + sensitive: false, + default: null, + ...field, + })), + extra: null, +}; + +types.push({ tag: 'object', val: database }); + export function info() { return { version: '1', description: '', baseUrl: '' }; } diff --git a/packages/forge/test/plugin.test.js b/packages/forge/test/plugin.test.js index be76a75f2..2fb62b928 100644 --- a/packages/forge/test/plugin.test.js +++ b/packages/forge/test/plugin.test.js @@ -7,7 +7,7 @@ import { test } from 'node:test'; const hooks = registerHooks({ resolve(specifier, context, nextResolve) { return nextResolve( - specifier === 'forge:plugin/forge@0.3.0' + specifier === 'forge:plugin/forge@0.4.0' ? new URL('./fixtures/metadata-host.js', import.meta.url).href : specifier, context, @@ -38,7 +38,7 @@ test('UUIDs are a distinct scalar through the plugin API', () => { definePlugin({ generate(forge) { assert.deepEqual( - forge.models.slice(3).map((model) => model.type), + forge.models.slice(3, 8).map((model) => model.type), [ { kind: 'scalar', scalar: 'string' }, { kind: 'scalar', scalar: 'uuid' }, @@ -51,3 +51,24 @@ test('UUIDs are a distinct scalar through the plugin API', () => { }, }).generate(); }); + +test('required write-only fields can be distinguished through the plugin API', () => { + definePlugin({ + generate(forge) { + const database = forge.models.at(-1).type; + assert.equal(database.kind, 'object'); + assert.deepEqual( + database.fields.map((field) => [field.wireName, field.required, field.writeOnly]), + [ + ['host', true, false], + ['password', true, true], + ], + ); + assert.deepEqual( + database.fields.filter((field) => !field.writeOnly).map((field) => field.wireName), + ['host'], + ); + return []; + }, + }).generate(); +}); diff --git a/src/link.rs b/src/link.rs index e77314894..6a9f947e1 100644 --- a/src/link.rs +++ b/src/link.rs @@ -659,6 +659,7 @@ impl<'d> Builder<'d> { description: body.description, deprecated: false, read_only: false, + write_only: false, sensitive: false, default: None, }); @@ -786,6 +787,7 @@ impl<'d> Builder<'d> { description: Str::default(), deprecated: false, read_only: false, + write_only: false, sensitive: false, default: None, }, @@ -833,6 +835,7 @@ impl<'d> Builder<'d> { for mut field in more.drain(..) { // A client sends every parameter. field.read_only = false; + field.write_only = false; fields.push(field); locations.push(Location::Query { exploded: true }); } @@ -944,6 +947,7 @@ impl<'d> Builder<'d> { description: self.strings.push(description), deprecated, read_only: false, + write_only: false, sensitive: self.schema_flags(param.schema).sensitive, default: self.schema_default(param.schema), } @@ -1618,6 +1622,7 @@ impl<'d> Builder<'d> { description: self.strings.push(description), deprecated, read_only: flags.read_only, + write_only: flags.write_only, sensitive: flags.sensitive, default: self.schema_default(property), }); @@ -2004,13 +2009,13 @@ impl<'d> Builder<'d> { None } - /// Returns whether `schema` is read-only and sensitive, as a field - /// gives them: whether it or any schema along its chain of `$ref`s has - /// `readOnly: true`, and `x-sensitive: true`. + /// Returns the field flags of `schema`: whether it or any schema along + /// its chain of `$ref`s has `readOnly: true`, `writeOnly: true` or + /// `x-sensitive: true`. /// - /// A `readOnly` next to a `$ref` counts from OpenAPI 3.1, and is - /// ignored in 3.0, as every keyword next to a `$ref` is. An - /// `x-sensitive` next to a `$ref` counts in every version, since tools + /// A `readOnly` or `writeOnly` next to a `$ref` counts from OpenAPI 3.1, + /// and is ignored in 3.0. An `x-sensitive` next to a `$ref` counts in + /// every version, since tools /// commonly honour extensions there. The chain stops at a `$ref` that /// does not point to a schema, which reading the schema's type /// reports, and after 32 `$ref`s, which lead back to themselves. @@ -2024,8 +2029,9 @@ impl<'d> Builder<'d> { let object = doc.get(object); let has_ref = !object.reference.is_empty(); flags.read_only |= object.read_only && !(has_ref && self.v30); + flags.write_only |= object.write_only && !(has_ref && self.v30); flags.sensitive |= object.sensitive; - if !has_ref || (flags.read_only && flags.sensitive) { + if !has_ref || (flags.read_only && flags.write_only && flags.sensitive) { break; } let Ok((target, _)) = self.resolve(doc.str(object.reference)) else { @@ -2144,6 +2150,7 @@ impl<'d> Builder<'d> { #[derive(Clone, Copy, Default)] struct SchemaFlags { read_only: bool, + write_only: bool, sensitive: bool, } diff --git a/src/link/openapi.rs b/src/link/openapi.rs index ba7fa4906..1de878bce 100644 --- a/src/link/openapi.rs +++ b/src/link/openapi.rs @@ -396,6 +396,8 @@ pub(super) struct SchemaObject { /// `readOnly`: the value appears in responses, and a client should not /// send it. pub(super) read_only: bool, + /// `writeOnly`: the client sends the value, but responses omit it. + pub(super) write_only: bool, /// Whether the schema is marked `x-sensitive: true`: the value is a /// secret, such as a password or a token. pub(super) sensitive: bool, @@ -1619,6 +1621,7 @@ keys! { Default = "default", Deprecated = "deprecated", ReadOnly = "readOnly", + WriteOnly = "writeOnly", Sensitive = "x-sensitive", ModelName = "x-fern-type-name", FieldName = "x-fern-property-name", @@ -1676,6 +1679,7 @@ impl ReadObject for SchemaObject { SchemaKey::Default => schema.default = Some(doc.add(map.next_value::()?)), SchemaKey::Deprecated => schema.deprecated = map.next_value()?, SchemaKey::ReadOnly => schema.read_only = map.next_value()?, + SchemaKey::WriteOnly => schema.write_only = map.next_value()?, SchemaKey::Sensitive => schema.sensitive = map.next_value()?, SchemaKey::ModelName => schema.model_name = map.next_value_seed(seed::(doc))?, SchemaKey::FieldName => schema.field_name = map.next_value_seed(seed::(doc))?, diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index 97220a570..dc135d21b 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -60,7 +60,7 @@ macro_rules! wit { wit!( r#" -package forge:plugin@0.3.0; +package forge:plugin@0.4.0; /// Read-only access to the forge a plugin runs on, which the host keeps. /// Each id is an index the host gave out. An id the host never gave out @@ -207,6 +207,8 @@ interface forge { deprecated: bool, /// Whether the server sets the property, so a client doesn't send it. read-only: bool, + /// Whether the client sends the property but responses omit it. + write-only: bool, /// Whether the value is a secret, such as a password or a token. sensitive: bool, /// The `default` of the property's schema. @@ -600,6 +602,7 @@ impl wit::Host for State { description: s(field.description), deprecated: field.deprecated, read_only: field.read_only, + write_only: field.write_only, sensitive: field.sensitive, default: field.default.map(|id| json(&forge.defaults[id.index()])), }) diff --git a/src/types.rs b/src/types.rs index 34aab173f..17ae63ff0 100644 --- a/src/types.rs +++ b/src/types.rs @@ -408,6 +408,26 @@ impl<'f> Field<'f> { self.data.read_only } + /// Reports whether the field is write-only: a client sends the + /// property, but the server does not return it in responses. + /// + /// Corresponds to `writeOnly: true` on the property's schema or any + /// schema along its chain of `$ref`s. A `writeOnly: false` does not + /// undo another's `true`. From OpenAPI 3.1, a `writeOnly` next to a + /// `$ref` counts; in 3.0 it is ignored. A value that is not a boolean + /// is an error. + /// + /// Forge keeps the field and its requiredness in the shared model. + /// A generator decides to leave write-only fields out of responses, + /// while keeping them in requests. + /// + /// As with [`is_read_only`](Self::is_read_only), a parameter's field, + /// a request's `body` wrapper and an undeclared path placeholder are + /// never write-only. + pub fn is_write_only(self) -> bool { + self.data.write_only + } + /// Reports whether the field holds a secret, such as a password or a /// token, which a client should mask when it shows it. /// @@ -607,6 +627,7 @@ pub(crate) struct FieldData { pub(crate) description: Str, pub(crate) deprecated: bool, pub(crate) read_only: bool, + pub(crate) write_only: bool, pub(crate) sensitive: bool, /// The default, in `Forge::defaults`. pub(crate) default: Option>, diff --git a/tests/parse/requests.rs b/tests/parse/requests.rs index 31ad98776..6c064596f 100644 --- a/tests/parse/requests.rs +++ b/tests/parse/requests.rs @@ -1,7 +1,7 @@ use forge::{Json, Location, Name, Number, Scalar, Type}; use crate::common::{ - field, field_names, intersection, kind, kinds, method, model_of, object, parse, parse_err, scalar, + field, field_names, intersection, kind, kinds, method, model_of, object, parse, parse_err, response_type, scalar, }; #[test] @@ -703,6 +703,28 @@ fn map_body_is_a_body_field() { assert_eq!(kind(field(params, "body").ty()), "Object"); } +#[test] +fn required_write_only_body_property_is_kept_in_the_shared_model() { + let forge = parse( + r##"{ + "openapi": "3.0.3", "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"post": {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "create", + "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Database"}}}}, + "responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Database"}}}}}}}}, + "components": {"schemas": {"Database": {"type": "object", "required": ["password"], "properties": { + "password": {"type": "string", "writeOnly": true} + }}}} + }"##, + ); + + let request = model_of(method(&forge, "a.create").request().unwrap()); + let response = model_of(response_type(&forge, "a.create")); + assert_eq!(request, response); + let password = field(object(request.ty()), "password"); + assert!(password.is_write_only()); + assert!(password.is_required()); +} + #[test] fn unknown_body_is_a_body_field() { let forge = parse( diff --git a/tests/parse/types/objects.rs b/tests/parse/types/objects.rs index 1b5139d46..bcfb16e68 100644 --- a/tests/parse/types/objects.rs +++ b/tests/parse/types/objects.rs @@ -76,14 +76,15 @@ fn field_is_read_only_when_its_property_is() { } #[test] -fn field_is_neither_read_only_nor_sensitive_by_default() { +fn field_flags_are_false_by_default() { let forge = parse(&responding_with( - r#"{"type": "object", "properties": {"name": {"type": "string"}, "shown": {"type": "string", "readOnly": false, "x-sensitive": false}}}"#, + r#"{"type": "object", "properties": {"name": {"type": "string"}, "shown": {"type": "string", "readOnly": false, "writeOnly": false, "x-sensitive": false}}}"#, )); let object = object(response_type(&forge, "a.get")); for name in ["name", "shown"] { assert!(!field(object, name).is_read_only(), "{name}"); + assert!(!field(object, name).is_write_only(), "{name}"); assert!(!field(object, name).is_sensitive(), "{name}"); } } @@ -97,6 +98,37 @@ fn read_only_that_is_not_a_boolean_is_an_error() { assert!(err.contains("expected a boolean"), "{err}"); } +#[test] +fn write_only_that_is_not_a_boolean_is_an_error() { + let err = parse_err(&responding_with( + r#"{"type": "object", "properties": {"password": {"type": "string", "writeOnly": "yes"}}}"#, + )); + + assert!(err.contains("expected a boolean"), "{err}"); +} + +#[test] +fn required_write_only_field_can_be_omitted_from_response_fields() { + let forge = parse(&responding_with( + r#"{"type": "object", "required": ["host", "password"], "properties": { + "host": {"type": "string"}, "password": {"type": "string", "writeOnly": true} + }}"#, + )); + + let object = object(response_type(&forge, "a.get")); + let password = field(object, "password"); + assert!(password.is_write_only()); + assert!(password.is_required()); + assert_eq!( + object + .fields() + .filter(|field| !field.is_write_only()) + .map(|field| field.wire_name()) + .collect::>(), + ["host"] + ); +} + #[test] fn field_is_sensitive_when_its_property_is() { let forge = parse(&responding_with( diff --git a/tests/parse/types/refs.rs b/tests/parse/types/refs.rs index 0e39e86e3..2809ac984 100644 --- a/tests/parse/types/refs.rs +++ b/tests/parse/types/refs.rs @@ -371,6 +371,43 @@ fn property_that_is_a_ref_is_sensitive_when_its_schema_is() { assert!(field(object(response_type(&forge, "a.get")), "token").is_sensitive()); } +#[test] +fn property_write_only_follows_a_chain_of_refs() { + let forge = parse(&responding_with_schemas( + r##"{"type": "object", "properties": {"password": {"$ref": "#/components/schemas/Password"}}}"##, + r##"{"Password": {"$ref": "#/components/schemas/Secret"}, "Secret": {"type": "string", "writeOnly": true}}"##, + )); + + assert!(field(object(response_type(&forge, "a.get")), "password").is_write_only()); +} + +#[test] +fn write_only_ref_annotations_follow_the_openapi_version() { + for version in ["3.0.3", "3.1.0", "3.2.0"] { + let spec = responding_with_schemas( + r##"{"type": "object", "properties": {"password": {"$ref": "#/components/schemas/Password", "writeOnly": true}}}"##, + r#"{"Password": {"type": "string"}}"#, + ).replace("3.0.3", version); + let forge = parse(&spec); + + assert_eq!( + field(object(response_type(&forge, "a.get")), "password").is_write_only(), + version != "3.0.3" + ); + } +} + +#[test] +fn write_only_false_does_not_undo_the_referenced_schemas_flag() { + let spec = responding_with_schemas( + r##"{"type": "object", "properties": {"password": {"$ref": "#/components/schemas/Password", "writeOnly": false}}}"##, + r#"{"Password": {"type": "string", "writeOnly": true}}"#, + ).replace("3.0.3", "3.1.0"); + let forge = parse(&spec); + + assert!(field(object(response_type(&forge, "a.get")), "password").is_write_only()); +} + #[test] fn sensitive_next_to_a_property_ref_counts_in_3_0() { let forge = parse(&responding_with_schemas( diff --git a/tests/wasm.rs b/tests/wasm.rs index 7278ead1d..3557512e9 100644 --- a/tests/wasm.rs +++ b/tests/wasm.rs @@ -178,7 +178,7 @@ const FEATURES: &str = r##"{ "components": {"schemas": { "Pet": {"type": "object", "properties": { "id": {"type": "string", "readOnly": true}, - "password": {"type": "string", "x-sensitive": true}, + "password": {"type": "string", "writeOnly": true, "x-sensitive": true}, "tags": {"type": "array", "items": {"type": "string"}, "minItems": 0, "maxItems": 3}, "dataset_scope": {"oneOf": [ {"type": "object", "properties": {"type": {"const": "default"}}, "required": ["type"], "additionalProperties": false}, @@ -194,7 +194,7 @@ const FEATURES: &str = r##"{ "owner": {"$ref": "#/components/schemas/Owner", "description": "Who has the pet."}, "nickname": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, "age": {"oneOf": [{"type": "string"}, {"type": "integer"}]} - }, "required": ["id"]}, + }, "required": ["id", "password"]}, "DatasetIDs": {"type": "array", "minItems": 1, "maxItems": 50, "items": {"$ref": "#/components/schemas/ID"}}, "ID": {"type": "string", "format": "uuid"}, "Owner": {"type": "object", "description": "A person.", "properties": { @@ -222,6 +222,7 @@ fn reads_all_of_the_forge() { "extension \"x-alias\"", "headers", "read only true", + "write only true", "sensitive true", "default Some(", "min items Some(0) max items Some(3)", diff --git a/tests/wasm/dump.rs b/tests/wasm/dump.rs index 86c788b1d..d8b360b1f 100644 --- a/tests/wasm/dump.rs +++ b/tests/wasm/dump.rs @@ -226,12 +226,13 @@ fn field(o: &mut String, depth: usize, method: Option>, field: Field< o, depth, format_args!( - "field {:?} {:?} required {} deprecated {} read only {} sensitive {} default {:?} location {location:?} description {:?}", + "field {:?} {:?} required {} deprecated {} read only {} write only {} sensitive {} default {:?} location {location:?} description {:?}", field.wire_name(), name(field.name()), field.is_required(), field.is_deprecated(), field.is_read_only(), + field.is_write_only(), field.is_sensitive(), field.default(), field.description(), diff --git a/tests/wasm/ts/src/lib.rs b/tests/wasm/ts/src/lib.rs index b289aa574..569736b81 100644 --- a/tests/wasm/ts/src/lib.rs +++ b/tests/wasm/ts/src/lib.rs @@ -329,6 +329,7 @@ impl Forge { description: f.description, deprecated: f.deprecated, read_only: f.read_only, + write_only: f.write_only, sensitive: f.sensitive, default: f.default.as_deref().map(Json::parse), }) @@ -464,6 +465,7 @@ struct FieldData { description: String, deprecated: bool, read_only: bool, + write_only: bool, sensitive: bool, default: Option, } @@ -989,6 +991,10 @@ impl<'f> Field<'f> { self.data.read_only } + pub fn is_write_only(self) -> bool { + self.data.write_only + } + pub fn is_sensitive(self) -> bool { self.data.sensitive } diff --git a/tests/wasm/ts/wit/forge.wit b/tests/wasm/ts/wit/forge.wit index af93aa10e..216cc2251 100644 --- a/tests/wasm/ts/wit/forge.wit +++ b/tests/wasm/ts/wit/forge.wit @@ -1,5 +1,5 @@ -package forge:plugin@0.3.0; +package forge:plugin@0.4.0; /// Read-only access to the forge a plugin runs on, which the host keeps. /// Each id is an index the host gave out. An id the host never gave out @@ -146,6 +146,8 @@ interface forge { deprecated: bool, /// Whether the server sets the property, so a client doesn't send it. read-only: bool, + /// Whether the client sends the property but responses omit it. + write-only: bool, /// Whether the value is a secret, such as a password or a token. sensitive: bool, /// The `default` of the property's schema.