use super::*;
pub(crate) fn render(
api: &Api,
dir: &str,
name: Option<&str>,
style: SdkClientStyle,
open_enums: bool,
preserve: bool,
) -> Result<GeneratedTree> {
let prepared = prepare_api(api);
let api = &prepared;
let mut tree = render_sdk_with_policy(api, dir, name, style, open_enums)?;
if !preserve {
return Ok(tree);
}
let root = normalized_output_dir(dir)?;
let root = if root.is_empty() || root == "." {
String::new()
} else {
format!("{root}/")
};
let package =
java_package_name(name.unwrap_or(&format!("io.poolster.{}", package_segment(&api.name))));
let path = package.replace('.', "/");
anyhow::ensure!(
!api.schemas.iter().any(|s| type_name(&s.name) == "Presence"),
"Presence model name reserved by preserve_presence"
);
for (index, schema) in api.schemas.iter().enumerate() {
if let SchemaKind::Object { fields, .. } = &schema.value.kind {
let names = native_names::field_names(
fields,
field_name,
&[
"clone",
"getClass",
"toString",
"hashCode",
"equals",
"wait",
"notify",
"notifyAll",
"finalize",
"poolsterIsDeclaredProperty",
"poolsterWirePropertyName",
],
);
for (filename, mut source) in render_model_parts(schema, &package, open_enums, index)? {
for field in fields.iter().filter(|f| !f.required) {
source = source.replace(
&format!(
"@JsonProperty({:?}) {} {}",
field.name,
java_type(&field.value),
names[&field.name].clone()
),
&format!(
"@JsonProperty({:?}) Presence<{}> {}",
field.name,
java_type(&field.value),
names[&field.name].clone()
),
);
}
// Convenience constructors must accept the same presence types as record components.
for field in fields.iter().filter(|f| !f.required) {
source = source.replace(
&format!("{} {}", java_type(&field.value), names[&field.name].clone()),
&format!(
"Presence<{}> {}",
java_type(&field.value),
names[&field.name].clone()
),
);
}
for field in fields.iter().filter(|field| !field.required) {
let native = &names[&field.name];
let ty = java_type(&field.value);
let model = type_name(&schema.name);
source = source.replace(
&format!("public {model} {native}({ty} value)"),
&format!("public {model} {native}(Presence<{ty}> value)"),
);
}
tree.replace(GeneratedFile::new(
format!("{root}src/main/java/{path}/model/{filename}"),
source,
)?)?;
}
}
}
tree.insert(GeneratedFile::new(
format!("{root}src/main/java/{path}/model/Presence.java"),
include_str!("../templates/presence.java.tmpl").replace("__PACKAGE__", &package),
)?)?;
tree.insert(GeneratedFile::new(format!("{root}PRESENCE.md"), "Optional fields use Presence<T> when preserve_presence is enabled. A null wrapper means omitted; Presence.of(null) sends explicit JSON null; Presence.of(value) sends a value. Required fields retain their native types. Deserialize with the generated Jackson annotations, then serialize with the same mapper to retain missing/null distinctions. This opt-in changes optional accessor/constructor types; default SDK ABI is unchanged. Unknown string enums require the separate open_enums setting. Union aliases retain raw JSON rather than promising complete variant validation.")?)?;
Ok(tree)
}
#[cfg(test)]
mod tests {
use super::*;
fn api() -> Api {
let mut note = SchemaValue::new(SchemaKind::String);
note.nullable = true;
Api {
name: "Presence".into(),
schemas: vec![Schema::new(
"Event",
SchemaValue::new(SchemaKind::Object {
fields: vec![
poolster_core::Field {
name: "note".into(),
value: note,
required: false,
annotations: Default::default(),
},
poolster_core::Field {
name: "count".into(),
value: SchemaValue::new(SchemaKind::Integer),
required: false,
annotations: Default::default(),
},
],
additional_properties: AdditionalProperties::Any,
}),
)],
..Default::default()
}
}
#[test]
fn opt_in_presence_changes_only_optional_fields() {
let tree = render(
&api(),
".",
Some("io.poolster.presence"),
SdkClientStyle::Flat,
false,
true,
)
.unwrap();
let source = tree
.get("src/main/java/io/poolster/presence/model/Event.java")
.unwrap();
assert!(source.contains("Presence<String> note"));
assert!(source.contains("Presence<Long> count"));
assert!(source.contains("public Event(Presence<String> note, Presence<Long> count)"));
assert!(
tree.get("src/main/java/io/poolster/presence/model/Presence.java")
.unwrap()
.contains("getAbsentValue")
);
let default = render(
&api(),
".",
Some("io.poolster.presence"),
SdkClientStyle::Flat,
false,
false,
)
.unwrap();
assert!(
!default
.get("src/main/java/io/poolster/presence/model/Event.java")
.unwrap()
.contains("Presence<")
);
}
#[test]
#[ignore = "requires JDK17+Maven"]
fn native_omitted_and_null_presence_roundtrip() {
let tree = poolster_core::engine::Packages::new()
.package(
crate::package("sdk")
.name("io.poolster.presence")
.with(crate::sdk().preserve_presence(true))
.with(crate::operation_tests()),
)
.generate(&api(), None)
.unwrap();
let dir = tempfile::tempdir().unwrap();
tree.write_to(dir.path()).unwrap();
std::fs::write(dir.path().join("sdk/src/test/java/io/poolster/presence/PresenceProbe.java"),r#"package io.poolster.presence;import io.poolster.presence.model.*;import com.fasterxml.jackson.databind.*;public class PresenceProbe{public static void main(String[]args)throws Exception{var mapper=new ObjectMapper();for(var wire:new String[]{"{}","{\"note\":null}","{\"note\":\"future\",\"count\":0}","{\"note\":null,\"future\":[false,0,null]}"}){var model=mapper.readValue(wire,Event.class);if(!mapper.readTree(wire).equals(mapper.readTree(mapper.writeValueAsString(model))))throw new AssertionError("presence wire");}var absent=mapper.readValue("{}",Event.class);if(absent.note()!=null)throw new AssertionError("omitted");var present=mapper.readValue("{\"note\":null}",Event.class);if(present.note()==null||present.note().value()!=null)throw new AssertionError("null");}}"#).unwrap();
let output = std::process::Command::new("mvn")
.args([
"-q",
"test-compile",
"org.codehaus.mojo:exec-maven-plugin:3.5.0:java",
"-Dexec.mainClass=io.poolster.presence.PresenceProbe",
"-Dexec.classpathScope=test",
])
.current_dir(dir.path().join("sdk"))
.output()
.unwrap();
assert!(
output.status.success(),
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
}