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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/forge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/`,
Expand Down
4 changes: 3 additions & 1 deletion packages/forge/plugin/forge.wit
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions packages/forge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions packages/forge/test/fixtures/metadata-host.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '' };
}
Expand Down
25 changes: 23 additions & 2 deletions packages/forge/test/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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' },
Expand All @@ -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();
});
21 changes: 14 additions & 7 deletions src/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,7 @@ impl<'d> Builder<'d> {
description: body.description,
deprecated: false,
read_only: false,
write_only: false,
sensitive: false,
default: None,
});
Expand Down Expand Up @@ -786,6 +787,7 @@ impl<'d> Builder<'d> {
description: Str::default(),
deprecated: false,
read_only: false,
write_only: false,
sensitive: false,
default: None,
},
Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -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),
});
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -2144,6 +2150,7 @@ impl<'d> Builder<'d> {
#[derive(Clone, Copy, Default)]
struct SchemaFlags {
read_only: bool,
write_only: bool,
sensitive: bool,
}

Expand Down
4 changes: 4 additions & 0 deletions src/link/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1676,6 +1679,7 @@ impl ReadObject for SchemaObject {
SchemaKey::Default => schema.default = Some(doc.add(map.next_value::<Json>()?)),
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::<ReadStr>(doc))?,
SchemaKey::FieldName => schema.field_name = map.next_value_seed(seed::<ReadStr>(doc))?,
Expand Down
5 changes: 4 additions & 1 deletion src/plugin/wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()])),
})
Expand Down
21 changes: 21 additions & 0 deletions src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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<Id<Json>>,
Expand Down
24 changes: 23 additions & 1 deletion tests/parse/requests.rs
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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(
Expand Down
36 changes: 34 additions & 2 deletions tests/parse/types/objects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
}
Expand All @@ -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::<Vec<_>>(),
["host"]
);
}

#[test]
fn field_is_sensitive_when_its_property_is() {
let forge = parse(&responding_with(
Expand Down
Loading