Skip to main content

poolster_plugin_java/
lib.rs

1//! Java 17+ SDK generation for Poolster.
2//!
3//! This plugin consumes Poolster's neutral [`Api`] AST and emits a self-contained,
4//! build-tool-agnostic Java package. The generated runtime deliberately uses
5//! the JDK's `java.net.http.HttpClient`; Jackson is the only runtime dependency
6//! and handles generated records, JSON bodies, and typed responses.
7
8mod docs;
9mod errors;
10#[cfg(test)]
11mod model_compat_tests;
12mod models;
13mod multipart;
14mod naming;
15mod native_names;
16mod operation_samples;
17mod operation_tests;
18mod operations;
19mod pagination;
20mod presence;
21mod resources;
22mod runtime;
23mod surface;
24use docs::*;
25use errors::*;
26#[cfg(test)]
27use models::render_model;
28use models::render_model_parts;
29use naming::*;
30pub use operation_tests::{OperationTests, operation_tests};
31#[cfg(test)]
32use operations::render_operation;
33use operations::{render_client_facade, render_operation_chunk};
34pub use package::NativeSdk;
35use pagination::{java_pagination, render_pagination_operation};
36use resources::{
37    render_resource_chunk, render_resource_facade, resource_accessor_name, resource_operations,
38};
39use runtime::*;
40use surface::*;
41#[cfg(test)]
42mod call_options_tests;
43mod oauth;
44pub use oauth::{OAuth, oauth};
45mod webhooks;
46use std::collections::{BTreeMap, BTreeSet};
47use std::fmt::Write;
48pub use webhooks::{Webhooks, webhooks};
49
50use anyhow::{Result, bail};
51use poolster_core::{
52    AdditionalProperties, Api, Field, GeneratedFile, GeneratedTree, Operation, OperationParameter,
53    OperationResponse, Schema, SchemaKind, SchemaValue, SdkClientStyle,
54};
55use serde_json::Value;
56
57const NOTICE: &str = "// Generated by Poolster. Do not edit.\n";
58/// Keeps even unusually verbose OpenAPI operations near a comfortably
59/// navigable source-file size on very large API descriptions.
60const OPERATIONS_PER_FILE: usize = 64;
61
62/// Generates a Java 17+ SDK below `output_dir`.
63///
64/// `package_name` is a Java package name, such as `com.poolster.email`. When
65/// absent, one is deterministically derived under `io.poolster`. The returned
66/// tree is not written automatically, allowing profiles to compose multiple
67/// language targets safely before materializing them.
68#[cfg(test)]
69fn render_test_sdk(
70    api: &Api,
71    output_dir: &str,
72    package_name: Option<&str>,
73) -> Result<GeneratedTree> {
74    render_sdk(api, output_dir, package_name, SdkClientStyle::Flat)
75}
76
77/// Generates a Java 17+ SDK with either the original direct-operation client
78/// or a resource-namespaced facade. In namespaced mode the direct operations
79/// remain available as direct entry points.
80#[cfg(test)]
81fn render_sdk(
82    api: &Api,
83    output_dir: &str,
84    package_name: Option<&str>,
85    style: SdkClientStyle,
86) -> Result<GeneratedTree> {
87    render_sdk_with_policy(api, output_dir, package_name, style, false)
88}
89
90fn render_sdk_with_policy(
91    api: &Api,
92    output_dir: &str,
93    package_name: Option<&str>,
94    style: SdkClientStyle,
95    open_enums: bool,
96) -> Result<GeneratedTree> {
97    let prepared = prepare_api(api);
98    let api = &prepared;
99    multipart::validate(api)?;
100
101    for operation in &api.operations {
102        let extension = poolster_core::poolster_extension(&operation.annotations, "pagination")
103            .or_else(|| operation.annotations.get("x-speakeasy-pagination"));
104        if extension
105            .and_then(|extension| extension.get("type"))
106            .and_then(Value::as_str)
107            == Some("page")
108        {
109            poolster_core::pagination::normalize_pagination(api, operation, None)?;
110        }
111    }
112    let root = normalized_output_dir(output_dir)?;
113    let package = package_name
114        .filter(|name| !name.trim().is_empty())
115        .map(java_package_name)
116        .unwrap_or_else(|| format!("io.poolster.{}", package_segment(&api.name)));
117    if package.is_empty() {
118        bail!("a Java package name could not be derived from the API name");
119    }
120    let package_path = package.replace('.', "/");
121    let artifact = format!("{}-sdk", package_segment(&api.name));
122    let version = package_version(&api.version);
123    let mut tree = GeneratedTree::default();
124
125    insert(
126        &mut tree,
127        &root,
128        "settings.gradle",
129        settings_gradle(&artifact),
130    )?;
131    insert(
132        &mut tree,
133        &root,
134        "build.gradle",
135        build_gradle(&package, &artifact, &version),
136    )?;
137    insert(
138        &mut tree,
139        &root,
140        "pom.xml",
141        pom_xml(&package, &artifact, &version),
142    )?;
143    insert(
144        &mut tree,
145        &root,
146        "README.md",
147        readme(api, &package, &artifact, style),
148    )?;
149    insert(
150        &mut tree,
151        &root,
152        "STYLE_GUIDE.md",
153        style_guide(api, &package, style),
154    )?;
155    insert(
156        &mut tree,
157        &root,
158        &format!("src/main/java/{package_path}/ApiException.java"),
159        api_exception(&package),
160    )?;
161    insert(
162        &mut tree,
163        &root,
164        &format!("src/main/java/{package_path}/ClientConfig.java"),
165        client_config(&package),
166    )?;
167    insert(
168        &mut tree,
169        &root,
170        &format!("src/main/java/{package_path}/RetryConfig.java"),
171        retry_config(&package),
172    )?;
173    insert(
174        &mut tree,
175        &root,
176        &format!("src/main/java/{package_path}/ClientHooks.java"),
177        client_hooks(&package),
178    )?;
179    insert(
180        &mut tree,
181        &root,
182        &format!("src/main/java/{package_path}/ClientCallOptions.java"),
183        include_str!("../templates/call_options.java.tmpl").replace("__PACKAGE__", &package),
184    )?;
185    multipart::emit(api, &root, &package, &mut tree)?;
186    for (index, schema) in api.schemas.iter().enumerate() {
187        for (filename, source) in render_model_parts(schema, &package, open_enums, index)? {
188            insert(
189                &mut tree,
190                &root,
191                &format!("src/main/java/{package_path}/model/{filename}"),
192                source,
193            )?;
194        }
195    }
196    insert(
197        &mut tree,
198        &root,
199        &format!("src/main/java/{package_path}/ClientBase.java"),
200        render_client_base(api, &package),
201    )?;
202    let operation_overhead = render_operation_chunk(api, &[], &package, 0).len() + 64;
203    let units = api
204        .operations
205        .iter()
206        .map(|operation| poolster_core::source_layout::SourceUnit {
207            bytes: render_operation_chunk(api, std::slice::from_ref(operation), &package, 0)
208                .len()
209                .saturating_sub(operation_overhead - 64),
210            resource: None,
211        })
212        .collect::<Vec<_>>();
213    let groups = poolster_core::source_layout::SourceLayout::Chunked {
214        max_file_bytes: 128 * 1024,
215        max_declarations: Some(OPERATIONS_PER_FILE),
216    }
217    .groups(&units, operation_overhead)?;
218    let operation_chunks = groups
219        .iter()
220        .map(|indices| {
221            indices
222                .iter()
223                .map(|index| api.operations[*index].clone())
224                .collect::<Vec<_>>()
225        })
226        .collect::<Vec<_>>();
227    for (index, operations) in operation_chunks.iter().enumerate() {
228        insert(
229            &mut tree,
230            &root,
231            &format!("src/main/java/{package_path}/internal/Operations{index:03}.java"),
232            render_operation_chunk(api, operations, &package, index),
233        )?;
234    }
235    insert(
236        &mut tree,
237        &root,
238        &format!("src/main/java/{package_path}/Client.java"),
239        render_client_facade(api, &package, style, operation_chunks.len()),
240    )?;
241    if style == SdkClientStyle::Namespaced {
242        for (resource, operations) in resource_operations(api) {
243            let overhead = render_resource_chunk(&resource, &[], &package, 0).len() + 64;
244            let units = operations
245                .iter()
246                .map(|operation| poolster_core::source_layout::SourceUnit {
247                    bytes: render_resource_chunk(
248                        &resource,
249                        std::slice::from_ref(operation),
250                        &package,
251                        0,
252                    )
253                    .len()
254                    .saturating_sub(overhead - 64),
255                    resource: None,
256                })
257                .collect::<Vec<_>>();
258            let groups = poolster_core::source_layout::SourceLayout::Chunked {
259                max_file_bytes: 128 * 1024,
260                max_declarations: Some(100),
261            }
262            .groups(&units, overhead)?;
263            for (index, indices) in groups.iter().enumerate() {
264                let operations = indices
265                    .iter()
266                    .map(|index| operations[*index].clone())
267                    .collect::<Vec<_>>();
268                insert(
269                    &mut tree,
270                    &root,
271                    &format!(
272                        "src/main/java/{package_path}/internal/resources/{resource}ResourcePart{index:03}.java"
273                    ),
274                    render_resource_chunk(&resource, &operations, &package, index),
275                )?;
276            }
277            insert(
278                &mut tree,
279                &root,
280                &format!("src/main/java/{package_path}/{resource}Resource.java"),
281                render_resource_facade(&resource, &package, groups.len()),
282            )?;
283        }
284    }
285    if api.schemas.is_empty() {
286        let imports = tree
287            .iter()
288            .filter(|(path, _)| path.extension().is_some_and(|ext| ext == "java"))
289            .map(|(path, source)| {
290                (
291                    path.to_path_buf(),
292                    source.replace(&format!("import {package}.model.*;\n"), ""),
293                )
294            })
295            .collect::<Vec<_>>();
296        for (path, source) in imports {
297            tree.replace(GeneratedFile::new(path, source)?)?;
298        }
299    }
300    let oversized = tree.iter().filter_map(|(path, source)| {
301        let native = path.extension().and_then(|value| value.to_str()) == Some("java");
302        (native && source.len() > 128 * 1024).then(|| serde_json::json!({"path":path,"bytes":source.len(),"max_file_bytes":128*1024,"reason":"Atomic native declaration or public facade exceeds the grouping budget; source was retained intact."}))
303    }).collect::<Vec<_>>();
304    if !oversized.is_empty() {
305        insert(
306            &mut tree,
307            &root,
308            ".poolster/source-layout-diagnostics.json",
309            serde_json::to_string_pretty(&oversized)?,
310        )?;
311    }
312    Ok(tree)
313}
314
315fn normalized_output_dir(output_dir: &str) -> Result<String> {
316    let root = output_dir.trim_matches('/');
317    if root.split('/').any(|part| part == "..") {
318        bail!("Java SDK output directory cannot contain parent-directory components");
319    }
320    Ok(root.to_owned())
321}
322
323fn insert(tree: &mut GeneratedTree, root: &str, path: &str, contents: String) -> Result<()> {
324    let path = match root {
325        "" | "." => path.to_owned(),
326        _ => format!("{root}/{path}"),
327    };
328    tree.insert(GeneratedFile::new(path, contents)?)
329}
330
331#[cfg(test)]
332mod tests;
333
334mod package;
335pub use package::{Java, PackageExt, Sdk, Settings, package, sdk};
336
337mod bundled_middleware;
338
339#[cfg(test)]
340mod source_layout_tests;