1use crate::{
3 Api, GeneratedFile, SchemaKind, SchemaValue,
4 engine::{Contract, Handle, Language, Meta, Plugin, PluginContext, Provision},
5};
6use anyhow::Result;
7use std::{fmt::Write, marker::PhantomData};
8
9#[derive(Clone, Debug)]
10pub struct ApiReferenceDocument {
11 pub path: String,
12 pub contents: String,
13}
14impl Contract for ApiReferenceDocument {
15 const NAME: &'static str = "poolster.api-reference.v1";
16}
17pub struct ApiReference<L: Language> {
18 meta: Meta,
19 output: String,
20 language: PhantomData<L>,
21}
22pub fn api_reference<L: Language>() -> ApiReference<L> {
23 ApiReference {
24 meta: Meta::new(),
25 output: "API_REFERENCE.md".into(),
26 language: PhantomData,
27 }
28}
29impl<L: Language> ApiReference<L> {
30 pub fn handle(&self) -> Handle<ApiReferenceDocument> {
31 self.meta.handle()
32 }
33 pub fn output(mut self, path: impl Into<String>) -> Self {
34 self.output = path.into();
35 self
36 }
37}
38impl<L: Language> Plugin<L> for ApiReference<L> {
39 fn kind(&self) -> &'static str {
40 "api-reference"
41 }
42 fn meta(&self) -> &Meta {
43 &self.meta
44 }
45 fn provides(&self) -> Vec<Provision> {
46 vec![Provision::of::<ApiReferenceDocument>()]
47 }
48 fn generate(&self, cx: &mut PluginContext<'_, L>) -> Result<()> {
49 let contents = render(cx.api);
50 cx.files
51 .emit(GeneratedFile::new(&self.output, &contents)?)?;
52 cx.publish(ApiReferenceDocument {
53 path: self.output.clone(),
54 contents,
55 })
56 }
57}
58fn escaped(value: &str) -> String {
59 value
60 .chars()
61 .map(|c| match c {
62 '&' => "&".into(),
63 '<' => "<".into(),
64 '>' => ">".into(),
65 '|' | '`' | '*' | '_' | '[' | ']' | '(' | ')' | '!' | '#' | ':' | '\\' => {
66 format!("&#{};", c as u32)
67 }
68 c if c.is_control() => " ".into(),
69 _ => c.to_string(),
70 })
71 .collect()
72}
73fn shape(schema: Option<&SchemaValue>, depth: usize) -> String {
74 let Some(schema) = schema else {
75 return "No schema declared".into();
76 };
77 if depth > 12 {
78 return "Nested schema (depth limit)".into();
79 }
80 let mut result = match &schema.kind {
81 SchemaKind::Any => "any".into(),
82 SchemaKind::Null => "null".into(),
83 SchemaKind::Boolean => "boolean".into(),
84 SchemaKind::Integer => "integer".into(),
85 SchemaKind::Number => "number".into(),
86 SchemaKind::String => "string".into(),
87 SchemaKind::Reference { reference } => {
88 if reference.starts_with('#') {
89 format!("Reference {}", escaped(reference))
90 } else {
91 "External reference (URI omitted)".into()
92 }
93 }
94 SchemaKind::Array { items } => format!("array of {}", shape(Some(items), depth + 1)),
95 SchemaKind::Object { fields, .. } => format!("object ({} fields)", fields.len()),
96 SchemaKind::OneOf { variants } => format!("oneOf ({} variants)", variants.len()),
97 SchemaKind::AnyOf { variants } => format!("anyOf ({} variants)", variants.len()),
98 SchemaKind::AllOf { variants } => format!("allOf ({} variants)", variants.len()),
99 SchemaKind::Not { .. } => "not schema".into(),
100 };
101 if schema.nullable || schema.nullish {
102 result.push_str("; nullable")
103 }
104 if schema.read_only {
105 result.push_str("; readOnly")
106 }
107 if schema.write_only {
108 result.push_str("; writeOnly")
109 }
110 result
111}
112pub fn render(api: &Api) -> String {
113 let mut out = format!(
114 "# {} API reference\n\nVersion: {}\n\nThis reference describes the normalized API contract, not generated client method names. Examples, defaults, source descriptions and credential values are omitted. External reference URIs are omitted.\n\n",
115 escaped(&api.name),
116 escaped(&api.version)
117 );
118 let mut operations = api.operations.iter().collect::<Vec<_>>();
119 operations.sort_by(|a, b| (&a.id, &a.path).cmp(&(&b.id, &b.path)));
120 for op in operations {
121 let _ = writeln!(
122 out,
123 "## {}\n\n{} {}\n",
124 escaped(&op.id),
125 op.method.as_str(),
126 escaped(&op.path)
127 );
128 out.push_str("### Parameters\n\n| Name | Location | Required | Schema |\n| --- | --- | --- | --- |\n");
129 for p in &op.parameters {
130 let _ = writeln!(
131 out,
132 "| {} | {} | {} | {} |",
133 escaped(&p.name),
134 escaped(&p.location),
135 p.required,
136 shape(p.schema.as_ref(), 0)
137 );
138 }
139 if op.parameters.is_empty() {
140 out.push_str("| — | — | — | None declared |\n")
141 }
142 if let Some(policy) = crate::idempotency::resolved(op) {
143 let _ = writeln!(
144 out,
145 "\n### Idempotency\n\nKey header: `{}`. {} Caller-supplied keys take precedence and each automatic retry reuses the same key. To retry a logical operation across SDK calls or process restarts, supply and persist your own key. The API server must implement deduplication.\n",
146 escaped(&policy.header),
147 if policy.auto_generate {
148 "An omitted key receives a fresh UUID per SDK call."
149 } else {
150 "Keys are caller-supplied; automatic generation is disabled."
151 }
152 );
153 }
154 if let Some(body) = &op.request_body {
155 let _ = writeln!(
156 out,
157 "\n### Request body\n\nRequired: {}\n\n| Media type | Schema |\n| --- | --- |",
158 body.required
159 );
160 for media in &body.media_types {
161 let _ = writeln!(
162 out,
163 "| {} | {} |",
164 escaped(&media.content_type),
165 shape(media.schema.as_ref(), 0)
166 );
167 }
168 }
169 out.push_str("\n### Responses\n\n| Status | Media type | Schema |\n| --- | --- | --- |\n");
170 for response in &op.responses {
171 if response.media_types.is_empty() {
172 let _ = writeln!(
173 out,
174 "| {} | — | No representation declared |",
175 escaped(&response.status)
176 );
177 }
178 for media in &response.media_types {
179 let _ = writeln!(
180 out,
181 "| {} | {} | {} |",
182 escaped(&response.status),
183 escaped(&media.content_type),
184 shape(media.schema.as_ref(), 0)
185 );
186 }
187 }
188 if op.responses.is_empty() {
189 out.push_str("| — | — | None declared |\n")
190 }
191 out.push('\n');
192 }
193 out.push_str("## Component schemas\n\n| Name | Shape |\n| --- | --- |\n");
194 let mut schemas = api.schemas.iter().collect::<Vec<_>>();
195 schemas.sort_by(|a, b| a.name.cmp(&b.name));
196 for schema in &schemas {
197 let _ = writeln!(
198 out,
199 "| {} | {} |",
200 escaped(&schema.name),
201 shape(Some(&schema.value), 0)
202 );
203 }
204 for schema in schemas {
205 if let SchemaKind::Object { fields, .. } = &schema.value.kind {
206 let _ = writeln!(
207 out,
208 "\n### {} fields\n\n| Name | Required | Shape |\n| --- | --- | --- |",
209 escaped(&schema.name)
210 );
211 for field in fields {
212 let _ = writeln!(
213 out,
214 "| {} | {} | {} |",
215 escaped(&field.name),
216 field.required,
217 shape(Some(&field.value), 0)
218 );
219 }
220 }
221 }
222 out
223}
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use crate::{
228 HttpMethod, Operation, OperationMediaType, OperationResponse, Schema, engine::Packages,
229 };
230 struct Test;
231 impl Language for Test {
232 const NAME: &'static str = "test";
233 type Settings = ();
234 type Workspace = ();
235 }
236 #[test]
237 fn reference_escapes_untrusted_text_and_omits_source_secrets() {
238 let mut schema = SchemaValue::new(SchemaKind::String);
239 schema.default = Some(serde_json::json!("secret-default"));
240 schema.enum_values = vec![serde_json::json!("secret-enum")];
241 schema.description = Some("secret-description".into());
242 let mut api = Api {
243 name: "<script>|API".into(),
244 schemas: vec![Schema::new("name|`", schema)],
245 ..Default::default()
246 };
247 api.operations.push(Operation {
248 id: "read[evil](https://evil)".into(),
249 method: HttpMethod::Get,
250 path: "/things/{id}".into(),
251 responses: vec![OperationResponse {
252 status: "200".into(),
253 description: Some("secret-response".into()),
254 media_types: vec![OperationMediaType {
255 content_type: "application/json".into(),
256 schema: Some(SchemaValue::reference(
257 "https://example.test/schema?secret-token",
258 )),
259 }],
260 }],
261 ..Default::default()
262 });
263 let text = render(&api);
264 assert!(!text.contains("<script>"));
265 assert!(!text.contains("secret"));
266 assert!(text.contains("application/json"));
267 assert!(text.contains("External reference (URI omitted)"));
268 assert!(text.contains("|"));
269 }
270 #[test]
271 fn reference_documents_resolved_package_policy() {
272 let api = Api {
273 operations: vec![crate::Operation {
274 id: "createOrder".into(),
275 method: crate::HttpMethod::Post,
276 path: "/orders".into(),
277 ..Default::default()
278 }],
279 ..Default::default()
280 };
281 let config = crate::idempotency::IdempotencyConfig {
282 operations: std::collections::BTreeMap::from([(
283 "createOrder".into(),
284 crate::idempotency::IdempotencyRule {
285 header: "X-Once".into(),
286 auto_generate: true,
287 ..Default::default()
288 },
289 )]),
290 ..Default::default()
291 };
292 let tree = Packages::new()
293 .package(
294 crate::engine::Package::<Test>::new("docs")
295 .idempotency(config)
296 .with(api_reference()),
297 )
298 .generate(&api, None)
299 .unwrap();
300 let document = tree.get("docs/API_REFERENCE.md").unwrap();
301 assert!(document.contains("Key header: `X-Once`"));
302 assert!(document.contains("fresh UUID per SDK call"));
303 assert!(document.contains("process restarts"));
304 assert!(document.contains("server must implement deduplication"));
305 assert!(api.operations[0].parameters.is_empty());
306 }
307
308 #[test]
309 fn plugin_emits_owned_custom_output_and_rejects_escaping_paths() {
310 let package = crate::engine::Package::<Test>::new("docs")
311 .with(api_reference().output("reference/API.md"));
312 let tree = Packages::new()
313 .package(package)
314 .generate(&Api::default(), None)
315 .unwrap();
316 assert!(tree.get("docs/reference/API.md").is_some());
317 assert!(
318 Packages::new()
319 .package(
320 crate::engine::Package::<Test>::new("docs")
321 .with(api_reference().output("../escape"))
322 )
323 .generate(&Api::default(), None)
324 .is_err()
325 );
326 }
327}