Skip to content

Commit 797269b

Browse files
committed
Add /openapi on the ingress to serve a merged OpenAPI spec
Implements #3108. The ingress /openapi endpoint (previously stubbed as NotImplemented) now returns a merged OpenAPI 3.1 spec combining all registered services. Schema component names, $ref pointers, and JSON Schema titles (when present) are all aligned between per-service and merged specs using PascalCase naming with a service-level prefix. Examples: service "greeter", handler "greet" -> GreeterGreetRequest / GreeterGreetResponse service "concurrent_greeter", handler "claude_sonnet" -> ConcurrentGreeterClaudeSonnetRequest The prefix can be overridden via the "dev.restate.openapi.prefix" metadata key, which can be set at both the service and handler level. Handler-level takes precedence over service-level. Examples with metadata: service metadata: dev.restate.openapi.prefix = "Payments" -> PaymentsProcessRequest (instead of PaymentServiceProcessRequest) handler metadata: dev.restate.openapi.prefix = "Checkout" -> CheckoutProcessRequest (overrides service prefix for that handler) When set at the service level, the prefix is also used as the per-service OpenAPI spec title.
1 parent 2883d05 commit 797269b

File tree

4 files changed

+337
-13
lines changed

4 files changed

+337
-13
lines changed

crates/ingress-http/src/handler/mod.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod awakeables;
1212
mod error;
1313
mod health;
1414
mod invocation;
15+
mod openapi;
1516
mod path_parsing;
1617
mod responses;
1718
mod service_handler;
@@ -76,10 +77,7 @@ where
7677
async move {
7778
match res? {
7879
RequestType::Health => this.handle_health(req),
79-
RequestType::OpenAPI => {
80-
// TODO
81-
Err(HandlerError::NotImplemented)
82-
}
80+
RequestType::OpenAPI => this.handle_openapi(req),
8381
RequestType::Awakeable(awakeable_request) => {
8482
this.handle_awakeable(req, awakeable_request).await
8583
}
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
// Copyright (c) 2023 - 2026 Restate Software, Inc., Restate GmbH.
2+
// All rights reserved.
3+
//
4+
// Use of this software is governed by the Business Source License
5+
// included in the LICENSE file.
6+
//
7+
// As of the Change Date specified in that file, in accordance with
8+
// the Business Source License, use of this software will be governed
9+
// by the Apache License, Version 2.0.
10+
11+
use bytes::Bytes;
12+
use http::{Method, Request, Response, StatusCode, header};
13+
use http_body_util::Full;
14+
use serde_json::{Map, Value};
15+
16+
use restate_core::TaskCenter;
17+
use restate_types::config::Configuration;
18+
use restate_types::schema::invocation_target::InvocationTargetResolver;
19+
use restate_types::schema::service::ServiceMetadataResolver;
20+
21+
use super::{APPLICATION_JSON, Handler};
22+
use crate::handler::error::HandlerError;
23+
24+
impl<Schemas, Dispatcher> Handler<Schemas, Dispatcher>
25+
where
26+
Schemas: ServiceMetadataResolver + InvocationTargetResolver + Send + Sync + 'static,
27+
{
28+
pub(crate) fn handle_openapi<B: http_body::Body>(
29+
&mut self,
30+
req: Request<B>,
31+
) -> Result<Response<Full<Bytes>>, HandlerError> {
32+
if req.method() != Method::GET {
33+
return Err(HandlerError::MethodNotAllowed);
34+
}
35+
36+
let ingress_address = TaskCenter::with_current(|tc| {
37+
Configuration::pinned()
38+
.ingress
39+
.advertised_address(tc.address_book())
40+
});
41+
42+
let schemas = self.schemas.pinned();
43+
let service_names = schemas.list_service_names();
44+
45+
// Collect individual OpenAPI specs. Schema names are already prefixed
46+
// with the service name (or dev.restate.openapi.prefix) at generation
47+
// time, so no renaming is needed here — just merge.
48+
let mut specs: Vec<Value> = Vec::with_capacity(service_names.len());
49+
for name in &service_names {
50+
if let Some(spec) =
51+
schemas.resolve_latest_service_openapi(name, ingress_address.clone())
52+
{
53+
specs.push(spec);
54+
}
55+
}
56+
57+
// Merge all specs into one
58+
let mut iter = specs.into_iter();
59+
let mut merged = iter.next().unwrap_or_else(empty_openapi);
60+
for next in iter {
61+
merge_openapi(&mut merged, next);
62+
}
63+
64+
// Replace per-service info with a generic title for the combined document
65+
if let Some(info) = merged.get_mut("info").and_then(Value::as_object_mut) {
66+
info.insert(
67+
"title".to_owned(),
68+
Value::String("Restate Services".to_owned()),
69+
);
70+
info.insert(
71+
"description".to_owned(),
72+
Value::String(
73+
"Combined OpenAPI specification for all registered Restate services."
74+
.to_owned(),
75+
),
76+
);
77+
}
78+
79+
Ok(Response::builder()
80+
.status(StatusCode::OK)
81+
.header(header::CONTENT_TYPE, APPLICATION_JSON)
82+
.body(Full::new(
83+
serde_json::to_vec(&merged)
84+
.expect("Serializing OpenAPI spec must not fail")
85+
.into(),
86+
))
87+
.unwrap())
88+
}
89+
}
90+
91+
fn empty_openapi() -> Value {
92+
serde_json::json!({
93+
"openapi": "3.1.0",
94+
"info": {
95+
"title": "Restate Services",
96+
"description": "Combined OpenAPI specification for all registered Restate services."
97+
},
98+
"paths": {}
99+
})
100+
}
101+
102+
/// Shallow merge of `other` OpenAPI JSON into `target`.
103+
/// Merges paths, component schemas/responses/parameters, tags, and servers.
104+
fn merge_openapi(target: &mut Value, other: Value) {
105+
let other = match other {
106+
Value::Object(m) => m,
107+
_ => return,
108+
};
109+
110+
let target = match target.as_object_mut() {
111+
Some(m) => m,
112+
None => return,
113+
};
114+
115+
// Merge paths
116+
if let Some(Value::Object(other_paths)) = other.get("paths") {
117+
let paths = target
118+
.entry("paths")
119+
.or_insert_with(|| Value::Object(Map::new()));
120+
if let Some(paths) = paths.as_object_mut() {
121+
for (path, item) in other_paths {
122+
paths.entry(path).or_insert_with(|| item.clone());
123+
}
124+
}
125+
}
126+
127+
// Merge components (schemas, responses, parameters)
128+
if let Some(Value::Object(other_components)) = other.get("components") {
129+
let components = target
130+
.entry("components")
131+
.or_insert_with(|| Value::Object(Map::new()));
132+
if let Some(components) = components.as_object_mut() {
133+
for section in ["schemas", "responses", "parameters"] {
134+
if let Some(Value::Object(other_section)) = other_components.get(section) {
135+
let target_section = components
136+
.entry(section)
137+
.or_insert_with(|| Value::Object(Map::new()));
138+
if let Some(target_section) = target_section.as_object_mut() {
139+
for (name, value) in other_section {
140+
target_section.entry(name).or_insert_with(|| value.clone());
141+
}
142+
}
143+
}
144+
}
145+
}
146+
}
147+
148+
// Merge tags (deduplicate by name)
149+
if let Some(Value::Array(other_tags)) = other.get("tags") {
150+
let tags = target
151+
.entry("tags")
152+
.or_insert_with(|| Value::Array(Vec::new()));
153+
if let Some(tags) = tags.as_array_mut() {
154+
for tag in other_tags {
155+
let tag_name = tag.get("name").and_then(Value::as_str);
156+
let already_present = tag_name.is_some_and(|name| {
157+
tags.iter()
158+
.any(|t| t.get("name").and_then(Value::as_str) == Some(name))
159+
});
160+
if !already_present {
161+
tags.push(tag.clone());
162+
}
163+
}
164+
}
165+
}
166+
167+
// Merge servers (deduplicate by url)
168+
if let Some(Value::Array(other_servers)) = other.get("servers") {
169+
let servers = target
170+
.entry("servers")
171+
.or_insert_with(|| Value::Array(Vec::new()));
172+
if let Some(servers) = servers.as_array_mut() {
173+
for server in other_servers {
174+
let server_url = server.get("url").and_then(Value::as_str);
175+
let already_present = server_url.is_some_and(|url| {
176+
servers
177+
.iter()
178+
.any(|s| s.get("url").and_then(Value::as_str) == Some(url))
179+
});
180+
if !already_present {
181+
servers.push(server.clone());
182+
}
183+
}
184+
}
185+
}
186+
}
187+
188+
#[cfg(test)]
189+
mod tests {
190+
use super::*;
191+
use serde_json::json;
192+
193+
#[test]
194+
fn merge_openapi_combines_paths_and_schemas() {
195+
let mut target = json!({
196+
"openapi": "3.1.0",
197+
"info": { "title": "A" },
198+
"paths": {
199+
"/a/greet": { "post": {} }
200+
},
201+
"components": {
202+
"schemas": {
203+
"AgreetRequest": { "type": "object" }
204+
}
205+
},
206+
"tags": [{ "name": "Send" }],
207+
"servers": [{ "url": "http://localhost:8080/" }]
208+
});
209+
210+
let other = json!({
211+
"openapi": "3.1.0",
212+
"info": { "title": "B" },
213+
"paths": {
214+
"/b/run": { "post": {} }
215+
},
216+
"components": {
217+
"schemas": {
218+
"BrunRequest": { "type": "string" }
219+
}
220+
},
221+
"tags": [{ "name": "Send" }, { "name": "Attach" }],
222+
"servers": [{ "url": "http://localhost:8080/" }, { "url": "http://other:9090/" }]
223+
});
224+
225+
merge_openapi(&mut target, other);
226+
227+
// Both paths present
228+
let paths = target["paths"].as_object().unwrap();
229+
assert!(paths.contains_key("/a/greet"));
230+
assert!(paths.contains_key("/b/run"));
231+
232+
// Both schemas present
233+
let schemas = target["components"]["schemas"].as_object().unwrap();
234+
assert!(schemas.contains_key("AgreetRequest"));
235+
assert!(schemas.contains_key("BrunRequest"));
236+
237+
// Tags deduplicated
238+
let tags = target["tags"].as_array().unwrap();
239+
let tag_names: Vec<&str> = tags.iter().filter_map(|t| t["name"].as_str()).collect();
240+
assert_eq!(tag_names, vec!["Send", "Attach"]);
241+
242+
// Servers deduplicated
243+
let servers = target["servers"].as_array().unwrap();
244+
let urls: Vec<&str> = servers.iter().filter_map(|s| s["url"].as_str()).collect();
245+
assert_eq!(urls, vec!["http://localhost:8080/", "http://other:9090/"]);
246+
}
247+
248+
#[test]
249+
fn merge_openapi_does_not_overwrite_existing_schemas() {
250+
let mut target = json!({
251+
"paths": {},
252+
"components": {
253+
"schemas": { "Shared": { "type": "object", "title": "from_target" } }
254+
}
255+
});
256+
257+
let other = json!({
258+
"paths": {},
259+
"components": {
260+
"schemas": { "Shared": { "type": "object", "title": "from_other" } }
261+
}
262+
});
263+
264+
merge_openapi(&mut target, other);
265+
266+
// Target's version wins
267+
assert_eq!(
268+
target["components"]["schemas"]["Shared"]["title"],
269+
"from_target"
270+
);
271+
}
272+
}

crates/types/src/schema/metadata/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ impl ServiceRevision {
465465

466466
service_openapi.to_openapi_contract(
467467
&self.name,
468+
&self.metadata,
468469
ingress_address,
469470
self.documentation.as_deref(),
470471
self.revision,

0 commit comments

Comments
 (0)