Skip to content
Open
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
49 changes: 36 additions & 13 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,19 @@ impl std::fmt::Debug for Group<'_> {
/// `x-fern-ignore` does not stop its aliases. An empty array makes no
/// method, an element that is not an object is ignored, and a value that
/// is not an array is an error. Aliases do not nest: an `x-forge-aliases`
/// in an alias is ignored, as are its keys that are not extensions.
/// in an alias is ignored, as are its keys that are neither extensions nor
/// `operationId`.
///
/// An alias says, as an operation would, the method's
/// [`group`](Method::group), [`name`](Method::name),
/// [`group`](Method::group) and [`name`](Method::name), with the two Fern
/// extensions or its own `operationId`, its
/// [`availability`](Method::availability), whether it is
/// [hidden](Method::is_hidden), its [`confirmation`](Method::confirmation)
/// and its [`extensions`](Method::extensions), and whether it is ignored
/// with `x-fern-ignore`. None of these come from the operation: an alias
/// without an extension does not have the operation's. The rest, from the
/// without one of these keys does not have the operation's, so an alias
/// without the Fern extensions or an `operationId` of its own makes no
/// method, whatever the operation's `operationId`. The rest, from the
/// HTTP method and path to the responses, is the operation's, and every
/// alias has it. Each alias is a method of its own group, so a model one
/// uses is placed as though each alias were a separate operation.
Expand All @@ -250,23 +254,42 @@ pub struct Method<'f> {
impl<'f> Method<'f> {
/// Returns the name of the method, which is unique within its group.
///
/// Corresponds to the `x-fern-sdk-method-name` extension of the
/// operation, or of the alias the method comes from. An operation or
/// alias without one, or with an empty one, is not a method, and one
/// with no letters or digits is an error. Two methods in a group with
/// equal names are an error, whether they are aliases or not.
/// An operation, or an alias the method comes from, is named in one of
/// two ways:
///
/// 1. With both the `x-fern-sdk-group-name` and the
/// `x-fern-sdk-method-name` extensions, neither empty. The name is
/// the `x-fern-sdk-method-name`.
/// 2. Without both, with an `operationId` of the form
/// `<group path>.<method name>`, split at the last dot. The name is
/// the part after it: `pets.list` is the method `list` in the group
/// `pets`, and `accounts.members.list` is the method `list` in the
/// group `members` inside `accounts`.
///
/// An operation or alias named in neither way, such as one whose
/// `operationId` has no dot, like `listPets`, is not a method, and is
/// not an error. A name with no letters or digits, as in the
/// `operationId` `pets.`, is an error. Two methods in a group with
/// equal names are an error, however they are named, and whether they
/// are aliases or not.
pub fn name(self) -> &'f Name {
&self.data().name
}

/// Returns the group the method is in.
///
/// Corresponds to the `x-fern-sdk-group-name` extension of the
/// operation, or of the alias the method comes from: a string of group
/// names separated by dots, such as `accounts.members`, or an array
/// with one group name per element, top level first. An operation or
/// alias without one, with an empty one, or with `x-fern-ignore: true`
/// is not a method. A group name with no letters or digits is an error.
/// operation, or of the alias the method comes from, when the method
/// is named by the Fern extensions, as [`name`](Method::name) says: a
/// string of group names separated by dots, such as
/// `accounts.members`, or an array with one group name per element,
/// top level first. Otherwise it is the part of the `operationId`
/// before the last dot, with group names separated by dots the same
/// way.
///
/// An operation or alias with `x-fern-ignore: true` is not a method,
/// however it is named. A group name with no letters or digits, as in
/// the `operationId` `a..b.list` or `.list`, is an error.
pub fn group(self) -> Group<'f> {
self.forge.group(self.data().group)
}
Expand Down
43 changes: 28 additions & 15 deletions src/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ use openapi::{
///
/// The methods come from the operations under `paths`, including those in
/// `additionalOperations` and in path items reached by `$ref`, and from
/// their aliases, as [`Method`](crate::Method) says. Webhooks, callbacks,
/// their aliases, as [`Method`](crate::Method) says. Each is named by its
/// `x-fern-sdk-group-name` and `x-fern-sdk-method-name`, or, without both,
/// by an `operationId` such as `pets.list`, as
/// [`Method::name`](crate::Method::name) says. Webhooks, callbacks,
/// response headers, cookie parameters, security, and extensions these
/// docs do not mention are ignored, except that those of an operation or
/// alias are kept for [`Method::extensions`](crate::Method::extensions).
Expand Down Expand Up @@ -566,23 +569,33 @@ impl<'d> Builder<'d> {
if definition.ignore {
return;
}
if definition.method_name.is_empty() {
return;
}
let group_names = doc.list(definition.group_names);
if group_names.is_empty() {
return;
}
let group_names: Vec<Name> = group_names.iter().map(|&name| Name::new(doc.str(name))).collect();
for name in &group_names {
if name.is_empty() {
self.error("x-fern-sdk-group-name has a group name with no letters or digits");
// Both Fern extensions name the method, or else an `operationId`
// with a dot does, as the group path and the name.
let fern_groups = doc.list(definition.group_names);
let (group_names, name, group_error, name_error): (Vec<Name>, Name, &str, &str) =
if !definition.method_name.is_empty() && !fern_groups.is_empty() {
(
fern_groups.iter().map(|&name| Name::new(doc.str(name))).collect(),
Name::new(doc.str(definition.method_name)),
"x-fern-sdk-group-name has a group name with no letters or digits",
"x-fern-sdk-method-name has no letters or digits",
)
} else if let Some((groups, name)) = doc.str(definition.operation_id).rsplit_once('.') {
(
groups.split('.').map(Name::new).collect(),
Name::new(name),
"operationId has a group name with no letters or digits",
"operationId has a method name with no letters or digits",
)
} else {
return;
}
};
if group_names.iter().any(Name::is_empty) {
self.error(group_error);
return;
}
let name = Name::new(doc.str(definition.method_name));
if name.is_empty() {
self.error("x-fern-sdk-method-name has no letters or digits");
self.error(name_error);
return;
}
let mut availability = None;
Expand Down
11 changes: 8 additions & 3 deletions src/link/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ pub(super) struct Operation {
pub(super) responses: Span<(Str, Id<Response>)>,
}

/// The extensions that make an Operation Object, or an alias of one in its
/// The keys that make an Operation Object, or an alias of one in its
/// `x-forge-aliases`, a method.
#[derive(Default)]
pub(super) struct Definition {
Expand All @@ -245,6 +245,9 @@ pub(super) struct Definition {
/// The `x-fern-sdk-group-name`: the group path, top level first, or
/// empty for none.
pub(super) group_names: Span<Str>,
/// The `operationId`, or empty for none. It names the method when the
/// two Fern extensions do not.
pub(super) operation_id: Str,
/// `x-fern-ignore`.
pub(super) ignore: bool,
/// `x-forge-hidden`.
Expand Down Expand Up @@ -1184,6 +1187,7 @@ keys! {
DefinitionKey {
MethodName = "x-fern-sdk-method-name",
GroupNames = "x-fern-sdk-group-name",
OperationId = "operationId",
Ignore = "x-fern-ignore",
Hidden = "x-forge-hidden",
Confirmation = "x-forge-require-confirmation",
Expand All @@ -1202,6 +1206,7 @@ impl Definition {
match key {
DefinitionKey::MethodName => self.method_name = map.next_value_seed(seed::<ReadStr>(doc))?,
DefinitionKey::GroupNames => self.group_names = map.next_value_seed(seed::<GroupNames>(doc))?,
DefinitionKey::OperationId => self.operation_id = map.next_value_seed(seed::<ReadStr>(doc))?,
DefinitionKey::Ignore => self.ignore = map.next_value()?,
DefinitionKey::Hidden => self.hidden = map.next_value()?,
DefinitionKey::Confirmation => {
Expand All @@ -1217,8 +1222,8 @@ impl Definition {
}

/// Reads an alias in `x-forge-aliases`: an object of the keys of a
/// definition and other extensions. Its keys that are not extensions are
/// skipped, and so is an `x-forge-aliases` in it, since aliases do not
/// definition and other extensions. Its other keys that are not extensions
/// are skipped, and so is an `x-forge-aliases` in it, since aliases do not
/// nest.
struct Alias;

Expand Down
98 changes: 98 additions & 0 deletions tests/parse/aliases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,3 +340,101 @@ fn model_used_by_aliases_in_one_group_is_in_that_group() {

assert_eq!(model(&forge, "M").group(), Some(group(&forge, "a")));
}

#[test]
fn alias_is_named_by_its_operation_id() {
let forge = parse(&aliased(
r#"[{"operationId": "a.pause"}, {"operationId": "b.c.resume", "x-a": 1}]"#,
"",
));

assert_eq!(methods(&forge), ["a.pause", "b.c.resume"]);
let resume = method(&forge, "b.c.resume");
let names: Vec<&str> = resume.extensions().map(|(name, _)| name).collect();
assert_eq!(names, ["x-a"]);
}

#[test]
fn alias_fern_extensions_win_over_its_operation_id() {
let forge = parse(&aliased(
r#"[{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause", "operationId": "b.resume"}]"#,
"",
));

assert_eq!(methods(&forge), ["a.pause"]);
}

#[test]
fn alias_with_one_fern_extension_falls_back_to_its_operation_id() {
let forge = parse(&aliased(
r#"[{"x-fern-sdk-group-name": "a", "operationId": "b.resume"}]"#,
"",
));

assert_eq!(methods(&forge), ["b.resume"]);
}

#[test]
fn alias_does_not_take_the_operation_id_of_the_operation() {
let forge = parse(&aliased(
r#"[{"x-fern-sdk-group-name": "a"}, {}, {"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"}]"#,
r#""operationId": "b.resume","#,
));

assert_eq!(methods(&forge), ["a.pause"]);
}

#[test]
fn alias_operation_id_without_a_dot_is_not_a_method() {
let forge = parse(&aliased(r#"[{"operationId": "pause"}]"#, ""));

assert_eq!(methods(&forge), Vec::<String>::new());
}

#[test]
fn ignored_alias_with_an_operation_id_is_not_a_method() {
let forge = parse(&aliased(
r#"[{"operationId": "a.pause", "x-fern-ignore": true}, {"operationId": "a.resume"}]"#,
"",
));

assert_eq!(methods(&forge), ["a.resume"]);
}

#[test]
fn alias_operation_id_name_without_letters_is_an_error_naming_the_alias() {
let err = parse_err(&aliased(r#"[{"operationId": "a."}, {"operationId": "a..pause"}]"#, ""));

assert!(
err.contains(
"PATCH /a/{id}/status: x-forge-aliases[0]: operationId has a method name with no letters or digits"
),
"{err}"
);
assert!(
err.contains(
"PATCH /a/{id}/status: x-forge-aliases[1]: operationId has a group name with no letters or digits"
),
"{err}"
);
}

#[test]
fn alias_operation_id_and_fern_alias_with_equal_names_are_an_error() {
let err = parse_err(&aliased(
r#"[{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "pause"}, {"operationId": "a.pause"}]"#,
"",
));

assert!(
err.contains(r#"x-forge-aliases[1]: method "pause" is also PATCH"#),
"{err}"
);
}

#[test]
fn alias_operation_id_that_is_not_a_string_is_an_error() {
let err = parse_err(&aliased(r#"[{"operationId": ["a", "pause"]}]"#, ""));

assert!(err.contains("expected a string"), "{err}");
}
Loading
Loading