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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ homepage = "https://github.com/sv-tools/roas"
repository = "https://github.com/sv-tools/roas"

[workspace.dependencies]
roas = { version = "0.20.1", path = "crates/roas", default-features = false }
roas-asyncapi = { version = "0.4", path = "crates/roas-asyncapi", default-features = false }
actix-web = { version = "4.15", default-features = false }
anyhow = "1.0.99"
axum = "0.8"
Expand Down
4 changes: 4 additions & 0 deletions crates/roas-arazzo-executor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ v1_0 = ["roas-arazzo/v1_0"]
# Ready-made HTTP clients built on `reqwest` — blocking and async.
# Without this the caller supplies its own `HttpClient`.
reqwest = ["dep:reqwest", "dep:tokio"]
# Complete source registries and graph loading through caller-configured roas loaders.
source-graph = ["dep:roas", "dep:roas-asyncapi", "roas-arazzo/v1_0"]

[dependencies]
enumset.workspace = true
roas = { workspace = true, optional = true, features = ["v2", "v3_0", "v3_1", "v3_2"] }
roas-asyncapi = { workspace = true, optional = true, features = ["v2_6", "v3_0", "v3_1"] }
roas-arazzo = { version = "0.3", path = "../roas-arazzo", features = ["v1_1"] }
regex.workspace = true
reqwest = { workspace = true, optional = true }
Expand Down
72 changes: 72 additions & 0 deletions crates/roas-arazzo-executor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,78 @@ The engine decides *what* to send and asks a client to send it. That is what let

Source descriptions are the same story: fetching them is IO, so the caller passes the parsed documents to `Options::source`. [`roas-file-fetcher`](https://crates.io/crates/roas-file-fetcher) and [`roas-http-fetcher`](https://crates.io/crates/roas-http-fetcher) do that job for the loader and do it here just as well.

### Document identities and source graphs

The optional `source-graph` feature adds `SourceRegistry` and bounded sync/async
traversal through a caller-configured `roas::loader::Loader`. It registers no file
or network fetchers itself. The existing `Options::source` API remains available
without this feature.

```rust,no_run
use roas::loader::Loader;
use roas_arazzo_executor::{Options, SourceLoadOptions, SourceRegistry, prepare};
use serde_json::Value;

# fn example(root_json: Value, loader: &mut Loader) -> Result<(), Box<dyn std::error::Error>> {
let mut registry = SourceRegistry::new();
let root = registry.insert("https://example.test/workflows/root.json", root_json)?;
// Insert every other supplied document here, before loading any links.
let loading = registry.load_sources(root, loader, &SourceLoadOptions::default())?;
for diagnostic in &loading.diagnostics {
eprintln!("{}: {diagnostic}", registry.document(diagnostic.owner)?.identity());
}
let options = Options::new().source_registry(&registry, root)?;
let description = registry.document(root)?.arazzo().expect("an Arazzo root");
let plan = prepare(description, &options)?;
// plan.execute(&mut client), or plan.execute_async(&mut client).await
# Ok(()) }
```

Documents retain their original value, retrieval URI, canonical identity, effective
reference base, and written version. Arazzo is deserialized in full before its
references are resolved. A relative `$self` resolves against the retrieval URI
(the final redirect location when exposed by the fetcher); document references
then resolve against that identity. `$self` fragments and conflicting documents
claiming the same identity/location are rejected. URI normalization removes
fragments for document lookup and handles dot segments/default ports; queries
remain distinct. It does not canonicalize filesystem symlinks or all percent escapes.

Canonical Arazzo identities follow
[identity-based referencing](https://spec.openapis.org/arazzo/v1.1.0.html#identity-based-referencing).
An Arazzo retrieval URL different from its `$self` is accepted only with
`SourceLoadOptions::retrieval_aliases = true`, a compatibility extension. Explicit
`override_source(owner, name, target)` is also available. Aliases and
`override_base_url` are scoped to the owning document; identical names in different
documents never overwrite each other. Explicit `Options::source` / `base_url`
entries win over the registry adapter. `Options::source_document` exposes the
metadata of registry-backed sources, and returns `None` for legacy sources.
Registry-backed options (including cloned options and source aliases) share the
loader's immutable raw value. They do not keep another full JSON copy. Arazzo also
has its parsed typed model; API documents remain raw values with checked versions.

Cycles are retained as back edges, not recursively expanded documents. Shared
dependencies reuse handles and loaded resources. The default limits are 256
existing documents plus distinct loader attempts, and depth 32 (root depth zero).
Failed attempts and different retrieval aliases also consume the document budget;
known cycles/diamonds do not consume additional depth. `root_sources` selects root
aliases; linked Arazzo documents are traversed in full. These limits are independent
of workflow step/retry/call-depth limits. Supplied documents must be inserted before
loading; the caller is responsible for bounding those inputs and response sizes.

Loading failures carry owner/alias/field locations and leave readable documents
available. Preparation decides whether that partial graph is sufficient: an
unrelated missing source need not block a qualified operation, but a missing
candidate source still prevents proving a bare `operationId` unique. Loading does
not silently certify a partial graph as complete.

Recognized versions are Arazzo 1.0/1.1, OpenAPI 2.0/3.0/3.1/3.2, and AsyncAPI
2.6/3.0/3.1. Arazzo 1.0 retains its wire version and is upconverted for execution.
API documents retain complete raw values and model-checked versions; loading is
**not** API structural or schema validation. AsyncAPI loading does not enable
broker execution. Cross-document workflow execution, external OpenAPI Path Item
resolution, and relative API-server computation are not added by this feature.
Document bases are distinct from API endpoint overrides.

## Testing a workflow

`testing::Fake` answers from a script and keeps what it was asked, so a workflow can be tested without a server:
Expand Down
15 changes: 15 additions & 0 deletions crates/roas-arazzo-executor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
//! Source descriptions are loaded the same way: fetching them is IO, so
//! the caller supplies the parsed documents through
//! [`Options::source`].
//! The optional `source-graph` feature adds a document registry and bounded
//! sync/async loading through explicitly configured `roas` fetchers. It retains
//! canonical Arazzo identities, retrieval metadata, local aliases and loading
//! diagnostics without adding IO to the execution state machine.
//!
//! ## Checked preparation
//!
Expand All @@ -53,6 +57,17 @@ mod report;
mod run;
mod runtime_syntax;
mod select;
#[cfg(feature = "source-graph")]
mod source_graph;
#[cfg(feature = "source-graph")]
mod source_registry;
#[cfg(feature = "source-graph")]
pub use source_graph::{SourceCycle, SourceLoadOptions, SourceLoadReport};
#[cfg(feature = "source-graph")]
pub use source_registry::{
DocumentId, SourceDiagnostic, SourceDocument, SourceError, SourceLink, SourceRegistry,
SourceVersion,
};

pub mod testing;

Expand Down
42 changes: 31 additions & 11 deletions crates/roas-arazzo-executor/src/operation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,26 @@ const METHODS: [&str; 8] = [
pub(crate) struct Source {
/// The URL the description was declared with.
pub url: String,
/// The parsed document.
pub document: Value,
pub data: SourceData,
}

/// Legacy callers own their value; registry-backed sources share the same
/// immutable document with the registry and loader, including cloned Options.
#[derive(Clone, Debug)]
pub(crate) enum SourceData {
Owned(Value),
#[cfg(feature = "source-graph")]
Registry(std::sync::Arc<crate::SourceDocument>),
}

impl Source {
pub(crate) fn document(&self) -> &Value {
match &self.data {
SourceData::Owned(value) => value,
#[cfg(feature = "source-graph")]
SourceData::Registry(document) => document.value(),
}
}
}

/// Where a step's request is going.
Expand Down Expand Up @@ -135,7 +153,7 @@ fn by_id<'s>(
let source = sources
.get(name)
.ok_or_else(|| OperationError::MissingSource(name.to_owned()))?;
let found = search(&source.document, id).ok_or_else(|| OperationError::Unknown {
let found = search(source.document(), id).ok_or_else(|| OperationError::Unknown {
operation: operation.to_owned(),
})?;
return Ok((
Expand All @@ -151,7 +169,7 @@ fn by_id<'s>(
// so, and if it is not, guessing would send the request somewhere
// the author did not name.
let mut hits = sources.iter().filter_map(|(name, source)| {
search(&source.document, operation).map(|found| {
search(source.document(), operation).map(|found| {
(
source,
Found {
Expand Down Expand Up @@ -222,7 +240,7 @@ fn by_path<'s>(
.get(&name)
.ok_or_else(|| OperationError::MissingSource(name.clone()))?;

if source.document.pointer(pointer).is_none() {
if source.document().pointer(pointer).is_none() {
return Err(bad("the document has nothing at that pointer"));
}
// `/paths/~1pets~1{petId}/get` — the pointer itself says which path
Expand Down Expand Up @@ -278,7 +296,7 @@ fn endpoint(
let base = base_urls
.get(&found.name)
.cloned()
.or_else(|| server(&source.document, &found.path, &found.method))
.or_else(|| server(source.document(), &found.path, &found.method))
.ok_or_else(|| OperationError::NoServer(named.to_owned()))?;
Ok(Endpoint {
method: found.method.to_uppercase(),
Expand Down Expand Up @@ -364,7 +382,7 @@ pub(crate) mod tests {
"petStore".to_owned(),
Source {
url: "https://api.example.com/openapi.json".to_owned(),
document: petstore(),
data: SourceData::Owned(petstore()),
},
)])
}
Expand Down Expand Up @@ -430,7 +448,7 @@ pub(crate) mod tests {
"mirror".to_owned(),
Source {
url: "https://mirror.example.com/openapi.json".to_owned(),
document: petstore(),
data: SourceData::Owned(petstore()),
},
);
let error = resolve(
Expand Down Expand Up @@ -540,7 +558,7 @@ pub(crate) mod tests {
"petStore".to_owned(),
Source {
url: "https://api.example.com/openapi.json".to_owned(),
document,
data: SourceData::Owned(document),
},
)]);
assert_eq!(
Expand Down Expand Up @@ -568,7 +586,7 @@ pub(crate) mod tests {
"petStore".to_owned(),
Source {
url: "https://api.example.com/swagger.json".to_owned(),
document,
data: SourceData::Owned(document),
},
)]);
assert_eq!(
Expand All @@ -589,7 +607,9 @@ pub(crate) mod tests {
"petStore".to_owned(),
Source {
url: "u".to_owned(),
document: json!({ "paths": { "/pets": { "get": { "operationId": "listPets" } } } }),
data: SourceData::Owned(
json!({ "paths": { "/pets": { "get": { "operationId": "listPets" } } } }),
),
},
)]);
assert_eq!(
Expand Down
2 changes: 1 addition & 1 deletion crates/roas-arazzo-executor/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ impl Options {
name.into(),
Source {
url: url.into(),
document,
data: crate::operation::SourceData::Owned(document),
},
);
self
Expand Down
Loading
Loading