poolster-plugin-java 0.5.0-alpha.1

Native Java SDK generator for Poolster
Documentation
//! Java 17+ SDK generation for Poolster.
//!
//! This plugin consumes Poolster's neutral [`Api`] AST and emits a self-contained,
//! build-tool-agnostic Java package. The generated runtime deliberately uses
//! the JDK's `java.net.http.HttpClient`; Jackson is the only runtime dependency
//! and handles generated records, JSON bodies, and typed responses.

mod docs;
mod errors;
#[cfg(test)]
mod model_compat_tests;
mod models;
mod multipart;
mod naming;
mod native_names;
mod operation_samples;
mod operation_tests;
mod operations;
mod pagination;
mod presence;
mod resources;
mod runtime;
mod surface;
use docs::*;
use errors::*;
#[cfg(test)]
use models::render_model;
use models::render_model_parts;
use naming::*;
pub use operation_tests::{OperationTests, operation_tests};
#[cfg(test)]
use operations::render_operation;
use operations::{render_client_facade, render_operation_chunk};
pub use package::NativeSdk;
use pagination::{java_pagination, render_pagination_operation};
use resources::{
    render_resource_chunk, render_resource_facade, resource_accessor_name, resource_operations,
};
use runtime::*;
use surface::*;
#[cfg(test)]
mod call_options_tests;
mod oauth;
pub use oauth::{OAuth, oauth};
mod webhooks;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write;
pub use webhooks::{Webhooks, webhooks};

use anyhow::{Result, bail};
use poolster_core::{
    AdditionalProperties, Api, Field, GeneratedFile, GeneratedTree, Operation, OperationParameter,
    OperationResponse, Schema, SchemaKind, SchemaValue, SdkClientStyle,
};
use serde_json::Value;

const NOTICE: &str = "// Generated by Poolster. Do not edit.\n";
/// Keeps even unusually verbose OpenAPI operations near a comfortably
/// navigable source-file size on very large API descriptions.
const OPERATIONS_PER_FILE: usize = 64;

/// Generates a Java 17+ SDK below `output_dir`.
///
/// `package_name` is a Java package name, such as `com.poolster.email`. When
/// absent, one is deterministically derived under `io.poolster`. The returned
/// tree is not written automatically, allowing profiles to compose multiple
/// language targets safely before materializing them.
#[cfg(test)]
fn render_test_sdk(
    api: &Api,
    output_dir: &str,
    package_name: Option<&str>,
) -> Result<GeneratedTree> {
    render_sdk(api, output_dir, package_name, SdkClientStyle::Flat)
}

/// Generates a Java 17+ SDK with either the original direct-operation client
/// or a resource-namespaced facade. In namespaced mode the direct operations
/// remain available as direct entry points.
#[cfg(test)]
fn render_sdk(
    api: &Api,
    output_dir: &str,
    package_name: Option<&str>,
    style: SdkClientStyle,
) -> Result<GeneratedTree> {
    render_sdk_with_policy(api, output_dir, package_name, style, false)
}

fn render_sdk_with_policy(
    api: &Api,
    output_dir: &str,
    package_name: Option<&str>,
    style: SdkClientStyle,
    open_enums: bool,
) -> Result<GeneratedTree> {
    let prepared = prepare_api(api);
    let api = &prepared;
    multipart::validate(api)?;

    for operation in &api.operations {
        let extension = poolster_core::poolster_extension(&operation.annotations, "pagination")
            .or_else(|| operation.annotations.get("x-speakeasy-pagination"));
        if extension
            .and_then(|extension| extension.get("type"))
            .and_then(Value::as_str)
            == Some("page")
        {
            poolster_core::pagination::normalize_pagination(api, operation, None)?;
        }
    }
    let root = normalized_output_dir(output_dir)?;
    let package = package_name
        .filter(|name| !name.trim().is_empty())
        .map(java_package_name)
        .unwrap_or_else(|| format!("io.poolster.{}", package_segment(&api.name)));
    if package.is_empty() {
        bail!("a Java package name could not be derived from the API name");
    }
    let package_path = package.replace('.', "/");
    let artifact = format!("{}-sdk", package_segment(&api.name));
    let version = package_version(&api.version);
    let mut tree = GeneratedTree::default();

    insert(
        &mut tree,
        &root,
        "settings.gradle",
        settings_gradle(&artifact),
    )?;
    insert(
        &mut tree,
        &root,
        "build.gradle",
        build_gradle(&package, &artifact, &version),
    )?;
    insert(
        &mut tree,
        &root,
        "pom.xml",
        pom_xml(&package, &artifact, &version),
    )?;
    insert(
        &mut tree,
        &root,
        "README.md",
        readme(api, &package, &artifact, style),
    )?;
    insert(
        &mut tree,
        &root,
        "STYLE_GUIDE.md",
        style_guide(api, &package, style),
    )?;
    insert(
        &mut tree,
        &root,
        &format!("src/main/java/{package_path}/ApiException.java"),
        api_exception(&package),
    )?;
    insert(
        &mut tree,
        &root,
        &format!("src/main/java/{package_path}/ClientConfig.java"),
        client_config(&package),
    )?;
    insert(
        &mut tree,
        &root,
        &format!("src/main/java/{package_path}/RetryConfig.java"),
        retry_config(&package),
    )?;
    insert(
        &mut tree,
        &root,
        &format!("src/main/java/{package_path}/ClientHooks.java"),
        client_hooks(&package),
    )?;
    insert(
        &mut tree,
        &root,
        &format!("src/main/java/{package_path}/ClientCallOptions.java"),
        include_str!("../templates/call_options.java.tmpl").replace("__PACKAGE__", &package),
    )?;
    multipart::emit(api, &root, &package, &mut tree)?;
    for (index, schema) in api.schemas.iter().enumerate() {
        for (filename, source) in render_model_parts(schema, &package, open_enums, index)? {
            insert(
                &mut tree,
                &root,
                &format!("src/main/java/{package_path}/model/{filename}"),
                source,
            )?;
        }
    }
    insert(
        &mut tree,
        &root,
        &format!("src/main/java/{package_path}/ClientBase.java"),
        render_client_base(api, &package),
    )?;
    let operation_overhead = render_operation_chunk(api, &[], &package, 0).len() + 64;
    let units = api
        .operations
        .iter()
        .map(|operation| poolster_core::source_layout::SourceUnit {
            bytes: render_operation_chunk(api, std::slice::from_ref(operation), &package, 0)
                .len()
                .saturating_sub(operation_overhead - 64),
            resource: None,
        })
        .collect::<Vec<_>>();
    let groups = poolster_core::source_layout::SourceLayout::Chunked {
        max_file_bytes: 128 * 1024,
        max_declarations: Some(OPERATIONS_PER_FILE),
    }
    .groups(&units, operation_overhead)?;
    let operation_chunks = groups
        .iter()
        .map(|indices| {
            indices
                .iter()
                .map(|index| api.operations[*index].clone())
                .collect::<Vec<_>>()
        })
        .collect::<Vec<_>>();
    for (index, operations) in operation_chunks.iter().enumerate() {
        insert(
            &mut tree,
            &root,
            &format!("src/main/java/{package_path}/internal/Operations{index:03}.java"),
            render_operation_chunk(api, operations, &package, index),
        )?;
    }
    insert(
        &mut tree,
        &root,
        &format!("src/main/java/{package_path}/Client.java"),
        render_client_facade(api, &package, style, operation_chunks.len()),
    )?;
    if style == SdkClientStyle::Namespaced {
        for (resource, operations) in resource_operations(api) {
            let overhead = render_resource_chunk(&resource, &[], &package, 0).len() + 64;
            let units = operations
                .iter()
                .map(|operation| poolster_core::source_layout::SourceUnit {
                    bytes: render_resource_chunk(
                        &resource,
                        std::slice::from_ref(operation),
                        &package,
                        0,
                    )
                    .len()
                    .saturating_sub(overhead - 64),
                    resource: None,
                })
                .collect::<Vec<_>>();
            let groups = poolster_core::source_layout::SourceLayout::Chunked {
                max_file_bytes: 128 * 1024,
                max_declarations: Some(100),
            }
            .groups(&units, overhead)?;
            for (index, indices) in groups.iter().enumerate() {
                let operations = indices
                    .iter()
                    .map(|index| operations[*index].clone())
                    .collect::<Vec<_>>();
                insert(
                    &mut tree,
                    &root,
                    &format!(
                        "src/main/java/{package_path}/internal/resources/{resource}ResourcePart{index:03}.java"
                    ),
                    render_resource_chunk(&resource, &operations, &package, index),
                )?;
            }
            insert(
                &mut tree,
                &root,
                &format!("src/main/java/{package_path}/{resource}Resource.java"),
                render_resource_facade(&resource, &package, groups.len()),
            )?;
        }
    }
    if api.schemas.is_empty() {
        let imports = tree
            .iter()
            .filter(|(path, _)| path.extension().is_some_and(|ext| ext == "java"))
            .map(|(path, source)| {
                (
                    path.to_path_buf(),
                    source.replace(&format!("import {package}.model.*;\n"), ""),
                )
            })
            .collect::<Vec<_>>();
        for (path, source) in imports {
            tree.replace(GeneratedFile::new(path, source)?)?;
        }
    }
    let oversized = tree.iter().filter_map(|(path, source)| {
        let native = path.extension().and_then(|value| value.to_str()) == Some("java");
        (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."}))
    }).collect::<Vec<_>>();
    if !oversized.is_empty() {
        insert(
            &mut tree,
            &root,
            ".poolster/source-layout-diagnostics.json",
            serde_json::to_string_pretty(&oversized)?,
        )?;
    }
    Ok(tree)
}

fn normalized_output_dir(output_dir: &str) -> Result<String> {
    let root = output_dir.trim_matches('/');
    if root.split('/').any(|part| part == "..") {
        bail!("Java SDK output directory cannot contain parent-directory components");
    }
    Ok(root.to_owned())
}

fn insert(tree: &mut GeneratedTree, root: &str, path: &str, contents: String) -> Result<()> {
    let path = match root {
        "" | "." => path.to_owned(),
        _ => format!("{root}/{path}"),
    };
    tree.insert(GeneratedFile::new(path, contents)?)
}

#[cfg(test)]
mod tests;

mod package;
pub use package::{Java, PackageExt, Sdk, Settings, package, sdk};

mod bundled_middleware;

#[cfg(test)]
mod source_layout_tests;