poolster-plugin-java 0.5.0-alpha.1

Native Java SDK generator for Poolster
Documentation
//! Java pagination declarations and generated continuation methods.

use super::*;

/// Pagination replays the normal generated operation. Cursor fields can live
/// in query, headers, or a required path parameter because the operation keeps
/// ownership of serialization, auth, retries, hooks, and error handling.
#[derive(Clone, Debug)]
pub(super) enum JavaPagination {
    Url {
        next_url_path: String,
    },
    Cursor {
        field: String,
        next_cursor_path: String,
    },
    OffsetLimit {
        step: JavaOffsetStep,
        limit_field: Option<String>,
        results_path: Option<String>,
        num_pages_path: Option<String>,
    },
}

#[derive(Clone, Debug)]
pub(super) enum JavaOffsetStep {
    Page { field: String },
    Offset { field: String },
}

pub(super) fn java_pagination(operation: &Operation) -> Option<JavaPagination> {
    if !matches!(response_surface(operation), ResponseSurface::Json(_)) {
        return None;
    }
    let extension = poolster_core::poolster_extension(&operation.annotations, "pagination")
        .or_else(|| operation.annotations.get("x-speakeasy-pagination"))?
        .as_object()?;
    let inputs = extension.get("inputs")?.as_array()?;
    let outputs = extension.get("outputs")?.as_object()?;
    for (role, selector) in outputs {
        if matches!(
            role.as_str(),
            "nextUrl" | "nextCursor" | "results" | "numPages"
        ) {
            poolster_core::pagination::Selector::parse(selector.as_str()?).ok()?;
        }
    }
    match extension.get("type").and_then(Value::as_str) {
        Some("url") => Some(JavaPagination::Url {
            next_url_path: outputs.get("nextUrl")?.as_str()?.to_owned(),
        }),
        Some("cursor") => Some(JavaPagination::Cursor {
            field: java_cursor_parameter_field(operation, inputs)?,
            next_cursor_path: outputs.get("nextCursor")?.as_str()?.to_owned(),
        }),
        Some("offsetLimit" | "page") => {
            let page = java_pagination_query_field(operation, inputs, "page", true);
            let offset = java_pagination_query_field(operation, inputs, "offset", true);
            let step = match (page, offset) {
                (Some(field), _) => JavaOffsetStep::Page { field },
                (None, Some(field)) => JavaOffsetStep::Offset { field },
                (None, None) => return None,
            };
            let results_path = outputs
                .get("results")
                .and_then(Value::as_str)
                .map(str::to_owned);
            let num_pages_path = outputs
                .get("numPages")
                .and_then(Value::as_str)
                .map(str::to_owned);
            match &step {
                JavaOffsetStep::Page { .. }
                    if num_pages_path.is_none() && results_path.is_none() =>
                {
                    return None;
                }
                JavaOffsetStep::Offset { .. } if results_path.is_none() => return None,
                _ => {}
            }
            Some(JavaPagination::OffsetLimit {
                step,
                limit_field: java_pagination_query_field(operation, inputs, "limit", true),
                results_path,
                num_pages_path,
            })
        }
        _ => None,
    }
}

fn java_cursor_parameter_field(operation: &Operation, inputs: &[Value]) -> Option<String> {
    let declared = inputs
        .iter()
        .find(|input| input.get("type").and_then(Value::as_str) == Some("cursor"))?
        .as_object()?;
    if !matches!(
        declared.get("in").and_then(Value::as_str),
        Some("parameters") | None
    ) {
        return None;
    }
    let name = declared.get("name")?.as_str()?;
    let parameter = operation.parameters.iter().find(|parameter| {
        parameter.name == name
            && matches!(parameter.location.as_str(), "query" | "header" | "path")
            && parameter
                .schema
                .as_ref()
                .is_some_and(|schema| matches!(schema.kind, SchemaKind::String))
    })?;
    if parameter.location == "path" && !parameter.required {
        return None;
    }
    Some(parameter_name(parameter))
}

/// Find an optional scalar query parameter that the generated Java record can
/// copy into the next request. The extension's `in: parameters` is only a
/// pointer to an actual OpenAPI query parameter; it is never enough by itself.
fn java_pagination_query_field(
    operation: &Operation,
    inputs: &[Value],
    kind: &str,
    integer: bool,
) -> Option<String> {
    let declared = inputs
        .iter()
        .find(|input| input.get("type").and_then(Value::as_str) == Some(kind))?
        .as_object()?;
    if !matches!(
        declared.get("in").and_then(Value::as_str),
        Some("parameters") | None
    ) {
        return None;
    }
    let name = declared.get("name")?.as_str()?;
    let parameter = operation.parameters.iter().find(|parameter| {
        parameter.name == name
            && parameter.location == "query"
            && (!parameter.required
                || poolster_core::poolster_extension(&operation.annotations, "pagination")
                    .or_else(|| operation.annotations.get("x-speakeasy-pagination"))
                    .and_then(|extension| extension.get("type"))
                    .and_then(Value::as_str)
                    == Some("page"))
    })?;
    match (
        integer,
        parameter.schema.as_ref().map(|schema| &schema.kind),
    ) {
        (false, Some(SchemaKind::String)) | (true, Some(SchemaKind::Integer)) => {
            Some(field_name(name))
        }
        _ => None,
    }
}

/// Emit a lazy `Iterable` rather than a transport-specific publisher. Each
/// call invokes the normal generated operation, keeping authentication,
/// headers, retries, hooks, error mapping, and request serialization intact.
pub(super) fn render_pagination_operation(
    output: &mut String,
    operation: &Operation,
    pagination: &JavaPagination,
) {
    if let JavaPagination::Url { next_url_path } = pagination {
        render_url_pagination_operation(output, operation, next_url_path);
        return;
    }
    let operation_name = type_name(&operation.id);
    let request_name = format!("{operation_name}Request");
    let method = method_name(&operation.id);
    let response = match response_surface(operation) {
        ResponseSurface::Json(schema) => operation_response_type(schema),
        _ => return,
    };
    let parameters = operation_parameters(operation);
    let has_input = !parameters.is_empty() || request_body_schema(operation).is_some();
    if !has_input {
        return;
    }
    let pages_method = format!("{method}Pages");
    let (field, continuation) = match pagination {
        JavaPagination::Url { .. } => unreachable!("URL pagination returns above"),
        JavaPagination::Cursor {
            field,
            next_cursor_path,
        } => {
            let copy = format!("{request_name}With{}", type_name(field));
            (
                field.as_str(),
                format!(
                    "                var cursor = poolsterJsonPath(mapper.valueToTree(page), {next_cursor_path:?});\n                if (cursor == null || !cursor.isTextual() || cursor.asText().isEmpty() || cursor.asText().equals(current.{field}())) {{ done = true; return page; }}\n                current = {copy}(current, cursor.asText());\n                return page;"
                ),
            )
        }
        JavaPagination::OffsetLimit {
            step,
            limit_field,
            results_path,
            num_pages_path,
        } => match step {
            JavaOffsetStep::Page { field } => {
                let copy = format!("{request_name}With{}", type_name(field));
                if num_pages_path.is_none() {
                    let results = results_path.as_deref().expect("validated page results");
                    let limit = limit_field
                        .as_deref()
                        .map_or_else(|| "null".to_owned(), |limit| format!("current.{limit}()"));
                    (
                        field.as_str(),
                        format!(
                            "                var results = poolsterJsonPath(mapper.valueToTree(page), {results:?});\n                Long limit = {limit};\n                if (results == null || !results.isArray() || results.size() == 0 || (limit != null && results.size() < limit) || current.{field}() == Long.MAX_VALUE) {{ done = true; return page; }}\n                current = {copy}(current, current.{field}() + 1L);\n                return page;"
                        ),
                    )
                } else {
                    (
                        field.as_str(),
                        format!(
                            "                var currentValue = current.{field}();\n                var numPages = poolsterJsonPath(mapper.valueToTree(page), {:?});\n                if (currentValue == null || currentValue == Long.MAX_VALUE || numPages == null || !numPages.isIntegralNumber() || !numPages.canConvertToLong()) {{ done = true; return page; }}\n                var nextValue = currentValue + 1L;\n                if (nextValue > numPages.asLong()) {{ done = true; return page; }}\n                current = {copy}(current, nextValue);\n                return page;",
                            num_pages_path
                                .as_deref()
                                .expect("validated page pagination")
                        ),
                    )
                }
            }
            JavaOffsetStep::Offset { field } => {
                let limit = limit_field
                    .as_deref()
                    .map_or_else(|| "null".to_owned(), |limit| format!("current.{limit}()"));
                let copy = format!("{request_name}With{}", type_name(field));
                (
                    field.as_str(),
                    format!(
                        "                var currentValue = current.{field}();\n                var results = poolsterJsonPath(mapper.valueToTree(page), {:?});\n                if (currentValue == null || results == null || !results.isArray()) {{ done = true; return page; }}\n                var resultCount = results.size();\n                Long limit = {limit};\n                if (resultCount == 0 || (limit != null && resultCount < limit)) {{ done = true; return page; }}\n                if (currentValue > Long.MAX_VALUE - resultCount) {{ done = true; return page; }}\n                current = {copy}(current, currentValue + resultCount);\n                return page;",
                        results_path
                            .as_deref()
                            .expect("validated offset pagination")
                    ),
                )
            }
        },
    };
    let field_type = operation_parameters(operation)
        .into_iter()
        .find(|parameter| parameter_name(parameter) == field)
        .map(parameter_type)
        .expect("validated pagination field is an operation parameter");
    let initial = match pagination {
        JavaPagination::OffsetLimit {
            step, limit_field, ..
        } => {
            let default = if matches!(step, JavaOffsetStep::Page { .. }) {
                1
            } else {
                0
            };
            let copy = format!("{request_name}With{}", type_name(field));
            let mut checks = format!(
                "        if (input.{field}() != null && input.{field}() < 0) throw new IllegalArgumentException(\"pagination must be nonnegative\");\n"
            );
            if let Some(limit) = limit_field {
                checks.push_str(&format!("        if (input.{limit}() != null && input.{limit}() <= 0) throw new IllegalArgumentException(\"pagination limit must be positive\");\n"));
            }
            (
                checks,
                format!("input.{field}() == null ? {copy}(input, {default}L) : input"),
            )
        }
        _ => (String::new(), "input".into()),
    };
    let checks = initial.0;
    let initial = initial.1;
    let _ = writeln!(
        output,
        "    /** Lazily fetches normal response pages using this operation's declared pagination contract. */\n    public java.lang.Iterable<{response}> {pages_method}({request_name} input) {{\n        Objects.requireNonNull(input, \"input\");\n{checks}        return () -> new java.util.Iterator<>() {{\n            private {request_name} current = {initial};\n            private boolean done;\n            private int pageCount;\n\n            @Override public boolean hasNext() {{ return !done; }}\n\n            @Override public {response} next() {{\n                if (done) throw new java.util.NoSuchElementException();\n                if (++pageCount >= 10000) done = true;\n                var page = {method}(current);\n{continuation}\n            }}\n        }};\n    }}\n"
    );
    render_pagination_request_copy(output, operation, &request_name, field, &field_type);
}

fn render_pagination_request_copy(
    output: &mut String,
    operation: &Operation,
    request_name: &str,
    target_field: &str,
    target_type: &str,
) {
    let mut values: Vec<String> = operation_parameters(operation)
        .iter()
        .map(|parameter| {
            let field = parameter_name(parameter);
            if field == target_field {
                "value".to_owned()
            } else {
                format!("input.{field}()")
            }
        })
        .collect();
    if request_body_schema(operation).is_some() {
        values.push("input.body()".to_owned());
    }
    let copy_name = format!("{request_name}With{}", type_name(target_field));
    let _ = writeln!(
        output,
        "    private static {request_name} {copy_name}({request_name} input, {target_type} value) {{\n        return new {request_name}({});\n    }}\n",
        values.join(", ")
    );
}

/// URL pagination has no mutable input field: after the first ordinary call,
/// the API-supplied continuation is the complete route and query.  We retain
/// the generated request's headers and body, and use a private same-origin
/// transport path rather than exposing a raw URL override to SDK consumers.
fn render_url_pagination_operation(
    output: &mut String,
    operation: &Operation,
    next_url_path: &str,
) {
    let operation_name = type_name(&operation.id);
    let request_name = format!("{operation_name}Request");
    let method = method_name(&operation.id);
    let ResponseSurface::Json(schema) = response_surface(operation) else {
        return;
    };
    let response = operation_response_type(schema);
    let has_input =
        !operation_parameters(operation).is_empty() || request_body_schema(operation).is_some();
    if !has_input {
        return;
    }
    let headers = operation
        .parameters
        .iter()
        .filter(|parameter| parameter.location == "header")
        .map(|parameter| {
            let accessor = format!("input.{}()", parameter_name(parameter));
            format!(
                "        if ({accessor} != null) headers.put({:?}, String.valueOf({accessor}));\n",
                parameter.name
            )
        })
        .collect::<String>();
    let body = if request_body_schema(operation).is_some() {
        "input.body()"
    } else {
        "null"
    };
    let continuation = format!("{method}FromPaginationUrl");
    let maps_declared_errors = !declared_error_responses(operation).is_empty();
    let error_mapping = if maps_declared_errors {
        let mapper = java_error_mapper_name(operation);
        format!(
            "        }} catch (ApiException error) {{\n            throw {mapper}(error);\n        }}"
        )
    } else {
        String::new()
    };
    let try_open = if maps_declared_errors {
        "        try {\n"
    } else {
        ""
    };
    let _ = writeln!(
        output,
        "    /** Lazily follows same-origin URL pages from this operation's declared contract. */\n    public java.lang.Iterable<{response}> {method}Pages({request_name} input) {{\n        Objects.requireNonNull(input, \"input\");\n        return () -> new java.util.Iterator<>() {{\n            private boolean first = true;\n            private boolean done;\n            private String nextUrl;\n            private int pageCount;\n\n            @Override public boolean hasNext() {{ return !done; }}\n\n            @Override public {response} next() {{\n                if (done) throw new java.util.NoSuchElementException();\n                if (++pageCount >= 10000) done = true;\n                var page = first ? {method}(input) : {continuation}(input, nextUrl);\n                first = false;\n                var next = poolsterJsonPath(mapper.valueToTree(page), {next_url_path:?});\n                if (next == null || !next.isTextual() || next.asText().isEmpty() || next.asText().equals(nextUrl)) done = true;\n                else nextUrl = next.asText();\n                return page;\n            }}\n        }};\n    }}\n\n    /** Private URL continuation that retains generated request policy. */\n    private {response} {continuation}({request_name} input, String paginationUrl) {{\n{try_open}        var headers = new java.util.LinkedHashMap<String, String>();\n{headers}        var response = requestPaginationUrlWithRetry({:?}, paginationUrl, headers, {body});\n        return decode(response, {}.class);\n{error_mapping}\n    }}\n",
        operation.method.as_str(),
        response_class(schema),
    );
}