use std::collections::BTreeMap;
use serde_json::Value;
use soothfast_spec::dialect::Operation;
use crate::model::{Field, Method, Model, Param, ParamLoc, Sdk, Ty};
use crate::{SdkKind, SdkOptions};
pub fn lower(ops: &[Operation], opts: &SdkOptions, kind: SdkKind) -> Result<Sdk, String> {
let mut notes = Vec::new();
let components = merge_components(ops)?;
let mut models = Vec::new();
let mut aliases = Vec::new();
for (name, schema) in &components {
match model_of(name, schema, kind, &mut notes) {
Some(model) => models.push(model),
None => aliases.push((name.clone(), ty_of(schema, &mut notes, name))),
}
}
let mut methods: Vec<Method> = ops
.iter()
.map(|op| method_of(op, opts, kind, &mut notes))
.collect::<Result<_, _>>()?;
methods.sort_by(|a, b| a.operation_id.cmp(&b.operation_id));
let reachable = reachable_names(&methods, &models, &aliases);
models.retain(|m| reachable.contains(&m.name));
aliases.retain(|(n, _)| reachable.contains(n));
Ok(Sdk {
models,
aliases,
methods,
notes: dedup(notes),
})
}
fn reachable_names(
methods: &[Method],
models: &[Model],
aliases: &[(String, Ty)],
) -> std::collections::BTreeSet<String> {
let mut reachable = std::collections::BTreeSet::new();
let mut queue: Vec<String> = Vec::new();
for m in methods {
for p in &m.params {
collect_named(&p.ty, &mut queue);
}
if let Some(body) = &m.body {
collect_named(body, &mut queue);
}
collect_named(&m.ok, &mut queue);
}
while let Some(name) = queue.pop() {
if !reachable.insert(name.clone()) {
continue;
}
if let Some(model) = models.iter().find(|m| m.name == name) {
for f in &model.fields {
collect_named(&f.ty, &mut queue);
}
}
if let Some((_, ty)) = aliases.iter().find(|(n, _)| *n == name) {
collect_named(ty, &mut queue);
}
}
reachable
}
fn collect_named(ty: &Ty, out: &mut Vec<String>) {
match ty {
Ty::Named(n) => out.push(n.clone()),
Ty::List(t) | Ty::Map(t) | Ty::Optional(t) => collect_named(t, out),
Ty::Union(arms) => arms.iter().for_each(|a| collect_named(a, out)),
_ => {}
}
}
fn merge_components(ops: &[Operation]) -> Result<BTreeMap<String, Value>, String> {
let mut components: BTreeMap<String, Value> = BTreeMap::new();
for op in ops {
for (name, schema) in &op.shape.components {
match components.get(name) {
Some(existing) if existing != schema => {
return Err(format!(
"component `{name}` has two different schemas \
(reached via operation `{}`)",
op.operation_id
));
}
Some(_) => {}
None => {
components.insert(name.clone(), schema.clone());
}
}
}
}
Ok(components)
}
fn model_of(name: &str, schema: &Value, kind: SdkKind, notes: &mut Vec<String>) -> Option<Model> {
let props = schema.get("properties")?.as_object()?;
let required: Vec<&str> = schema["required"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let mut taken = BTreeMap::new();
let fields = props
.iter()
.map(|(wire, field_schema)| {
let at = format!("{name}.{wire}");
Field {
attr: attr_for(wire, &at, kind, &mut taken, &reserved_fields(kind), notes),
wire: wire.clone(),
ty: ty_of(field_schema, notes, &at),
required: required.contains(&wire.as_str()),
doc: field_schema["description"].as_str().map(String::from),
}
})
.collect();
Some(Model {
name: name.to_string(),
doc: schema["description"].as_str().map(String::from),
fields,
})
}
fn method_of(
op: &Operation,
opts: &SdkOptions,
kind: SdkKind,
notes: &mut Vec<String>,
) -> Result<Method, String> {
let at = &op.operation_id;
let mut taken = BTreeMap::new();
let reserved = reserved_args(
kind,
op.shape.request.is_some(),
opts.paginated.contains(&op.operation_id),
);
let params = op
.shape
.parameters
.iter()
.map(|p| {
let location = match p.location.as_str() {
"path" => ParamLoc::Path,
"header" => ParamLoc::Header,
_ => ParamLoc::Query,
};
let reserved: &[&str] = match (kind, location) {
(SdkKind::TypeScript, ParamLoc::Path) | (SdkKind::Python, _) => &reserved,
_ => &[],
};
Param {
attr: attr_for(
&p.name,
&format!("{at}({})", p.name),
kind,
&mut taken,
reserved,
notes,
),
wire: p.name.clone(),
location,
ty: ty_of(&p.schema, notes, &format!("{at}({})", p.name)),
required: p.required,
doc: p.schema["description"].as_str().map(String::from),
}
})
.collect();
let body = op
.shape
.request
.as_ref()
.map(|b| ty_of(&b.schema, notes, &format!("{at}(body)")));
let (ok, ok_status) = success_of(op, notes);
Ok(Method {
name: method_name(&op.operation_id, kind),
operation_id: op.operation_id.clone(),
http_method: op.method.clone(),
path: op.path.clone(),
doc: op.summary.clone(),
params,
body,
ok,
ok_status,
paginated: opts.paginated.contains(&op.operation_id),
})
}
fn success_of(op: &Operation, notes: &mut Vec<String>) -> (Ty, u16) {
let success = op
.shape
.responses
.iter()
.filter_map(|(code, resp)| {
code.parse::<u16>()
.ok()
.filter(|s| (200..300).contains(s))
.map(|s| (s, resp))
})
.min_by_key(|(s, _)| *s);
match success {
Some((status, resp)) if !resp.content_type.is_empty() && !resp.schema.is_null() => {
let ty = ty_of(
&resp.schema,
notes,
&format!("{}[{status}]", op.operation_id),
);
(ty, status)
}
Some((status, _)) => (Ty::Unit, status),
None => (Ty::Unit, 200),
}
}
fn ty_of(schema: &Value, notes: &mut Vec<String>, at: &str) -> Ty {
if let Some(r) = schema["$ref"].as_str() {
let name = r.rsplit('/').next().unwrap_or(r);
return Ty::Named(name.to_string());
}
if let Some(arms) = schema["oneOf"].as_array() {
return Ty::Union(arms.iter().map(|a| ty_of(a, notes, at)).collect());
}
if let Some(values) = schema["enum"].as_array() {
let strings: Vec<String> = values
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
if strings.len() == values.len() && !strings.is_empty() {
return Ty::Literal(strings);
}
}
match &schema["type"] {
Value::Array(types) => {
let non_null: Vec<&str> = types
.iter()
.filter_map(|t| t.as_str())
.filter(|t| *t != "null")
.collect();
match non_null.as_slice() {
[one] => {
let base = ty_of(&serde_json::json!({ "type": one }), notes, at);
Ty::Optional(Box::new(base))
}
_ => Ty::Any,
}
}
Value::String(t) => match t.as_str() {
"string" => Ty::Str,
"integer" => Ty::Int,
"number" => Ty::Float,
"boolean" => Ty::Bool,
"null" => Ty::Unit,
"array" => match schema.get("items") {
Some(items) => Ty::List(Box::new(ty_of(items, notes, at))),
None => {
if schema.get("prefixItems").is_some() {
notes.push(format!("{at}: tuple schema lowered to a plain list"));
}
Ty::List(Box::new(Ty::Any))
}
},
"object" => object_ty(schema, notes, at),
_ => Ty::Any,
},
_ => Ty::Any,
}
}
fn object_ty(schema: &Value, notes: &mut Vec<String>, at: &str) -> Ty {
if schema.get("properties").is_some() {
notes.push(format!(
"{at}: anonymous object schema lowered to an open map"
));
return Ty::Map(Box::new(Ty::Any));
}
match schema.get("additionalProperties") {
Some(Value::Object(_)) => {
Ty::Map(Box::new(ty_of(&schema["additionalProperties"], notes, at)))
}
_ => Ty::Map(Box::new(Ty::Any)),
}
}
fn method_name(operation_id: &str, kind: SdkKind) -> String {
match kind {
SdkKind::Python => crate::python::naming::snake(operation_id),
SdkKind::TypeScript => crate::typescript::naming::camel(operation_id),
}
}
fn reserved_fields(kind: SdkKind) -> Vec<&'static str> {
match kind {
SdkKind::TypeScript => Vec::new(),
SdkKind::Python => vec!["self"],
}
}
fn reserved_args(kind: SdkKind, has_body: bool, paginated: bool) -> Vec<&'static str> {
let mut out = match kind {
SdkKind::TypeScript => vec!["options"],
SdkKind::Python => vec!["self"],
};
if has_body {
out.push("body");
}
if paginated && kind == SdkKind::Python {
out.push("limit");
}
out
}
fn attr_for(
wire: &str,
at: &str,
kind: SdkKind,
taken: &mut BTreeMap<String, String>,
reserved: &[&str],
notes: &mut Vec<String>,
) -> String {
let mut attr = match kind {
SdkKind::TypeScript => wire.to_string(),
SdkKind::Python => crate::python::naming::snake_attr(wire),
};
let clash = if reserved.contains(&attr.as_str()) {
Some("the argument the client gives itself".to_string())
} else {
taken
.get(&attr)
.filter(|other| *other != wire)
.map(|other| format!("`{other}` after snake_case"))
};
if let Some(other) = clash {
attr = match kind {
SdkKind::TypeScript => format!("{attr}_"),
SdkKind::Python => crate::python::naming::sanitize_ident(wire),
};
while taken.contains_key(&attr) || reserved.contains(&attr.as_str()) {
attr.push('_');
}
notes.push(format!(
"{at}: `{wire}` collides with {other}; emitted as `{attr}`"
));
}
taken.insert(attr.clone(), wire.to_string());
attr
}
fn dedup(mut notes: Vec<String>) -> Vec<String> {
notes.sort();
notes.dedup();
notes
}