progenitor/src/main.rs

1041 lines
33 KiB
Rust

#![allow(unused_imports)]
use anyhow::{bail, Result};
use openapiv3::OpenAPI;
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::Path;
fn save<P>(p: P, data: &str) -> Result<()>
where
P: AsRef<Path>,
{
let p = p.as_ref();
let mut f = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(p)?;
f.write_all(data.as_bytes())?;
f.flush()?;
Ok(())
}
fn load<P, T>(p: P) -> Result<T>
where
P: AsRef<Path>,
for<'de> T: Deserialize<'de>,
{
let p = p.as_ref();
let f = File::open(p)?;
Ok(serde_json::from_reader(f)?)
}
fn load_api<P>(p: P) -> Result<OpenAPI>
where
P: AsRef<Path>,
{
let api: OpenAPI = load(p)?;
if api.openapi != "3.0.3" {
/*
* XXX During development we are being very strict, but this should
* probably be relaxed.
*/
bail!("unexpected version {}", api.openapi);
}
if !api.servers.is_empty() {
bail!("servers not presently supported");
}
if !api.security.is_empty() {
bail!("security not presently supported");
}
if !api.tags.is_empty() {
bail!("tags not presently supported");
}
if let Some(components) = api.components.as_ref() {
if !components.security_schemes.is_empty() {
bail!("component security schemes not supported");
}
if !components.responses.is_empty() {
bail!("component responses not supported");
}
if !components.parameters.is_empty() {
bail!("component parameters not supported");
}
if !components.request_bodies.is_empty() {
bail!("component request bodies not supported");
}
if !components.headers.is_empty() {
bail!("component headers not supported");
}
if !components.links.is_empty() {
bail!("component links not supported");
}
if !components.callbacks.is_empty() {
bail!("component callbacks not supported");
}
/*
* XXX Ignoring "examples" and "extensions" for now.
* Explicitly allowing "schemas" through.
*/
}
/*
* XXX Ignoring "external_docs" and "extensions" for now, as they seem not
* to immediately affect our code generation.
*/
let mut opids = HashSet::new();
for p in api.paths.iter() {
match p.1 {
openapiv3::ReferenceOr::Reference { reference: _ } => {
bail!("path {} uses reference, unsupported", p.0);
}
openapiv3::ReferenceOr::Item(item) => {
/*
* Make sure every operation has an operation ID, and that each
* operation ID is only used once in the document.
*/
let mut id = |o: Option<&openapiv3::Operation>| -> Result<()> {
if let Some(o) = o {
if let Some(oid) = o.operation_id.as_ref() {
if !opids.insert(oid.to_string()) {
bail!("duplicate operation ID: {}", oid);
}
if !o.tags.is_empty() {
bail!("op {}: tags, unsupported", oid);
}
if !o.servers.is_empty() {
bail!("op {}: servers, unsupported", oid);
}
if !o.security.is_empty() {
bail!("op {}: security, unsupported", oid);
}
if o.responses.default.is_some() {
bail!("op {}: has response default", oid);
}
} else {
bail!("path {} is missing operation ID", p.0);
}
}
Ok(())
};
id(item.get.as_ref())?;
id(item.put.as_ref())?;
id(item.post.as_ref())?;
id(item.delete.as_ref())?;
id(item.options.as_ref())?;
id(item.head.as_ref())?;
id(item.patch.as_ref())?;
id(item.trace.as_ref())?;
if !item.servers.is_empty() {
bail!("path {} has servers; unsupported", p.0);
}
}
}
}
Ok(api)
}
trait ParameterDataExt {
fn render_type(&self) -> Result<String>;
}
impl ParameterDataExt for openapiv3::ParameterData {
fn render_type(&self) -> Result<String> {
use openapiv3::{SchemaKind, Type};
Ok(match &self.format {
openapiv3::ParameterSchemaOrContent::Schema(s) => {
let s = s.item()?;
match &s.schema_kind {
SchemaKind::Type(Type::String(st)) => {
if !st.format.is_empty() {
bail!("XXX format");
}
if st.pattern.is_some() {
bail!("XXX pattern");
}
if !st.enumeration.is_empty() {
bail!("XXX enumeration");
}
if st.min_length.is_some() || st.max_length.is_some() {
bail!("XXX min/max length");
}
"&str".to_string()
}
SchemaKind::Type(Type::Integer(it)) => {
let mut uint;
use openapiv3::VariantOrUnknownOrEmpty::Unknown;
if let Unknown(f) = &it.format {
if f == "uint" {
uint = true;
} else {
bail!("XXX unknown integer format {}", f);
}
} else {
bail!("XXX format {:?}", it.format);
}
if it.multiple_of.is_some() {
bail!("XXX multiple_of");
}
if it.exclusive_minimum || it.exclusive_maximum {
bail!("XXX exclusive");
}
if let Some(min) = it.minimum {
if min == 0 {
uint = true;
} else {
bail!("XXX invalid minimum: {}", min);
}
}
if it.maximum.is_some() {
bail!("XXX maximum");
}
if !it.enumeration.is_empty() {
bail!("XXX enumeration");
}
if uint {
"u64".to_string()
} else {
"i64".to_string()
}
}
x => bail!("unexpected type {:#?}", x),
}
}
x => bail!("XXX param format {:#?}", x),
})
}
}
trait ExtractJsonMediaType {
fn is_binary(&self) -> Result<bool>;
fn content_json(&self) -> Result<openapiv3::MediaType>;
}
impl ExtractJsonMediaType for openapiv3::RequestBody {
fn content_json(&self) -> Result<openapiv3::MediaType> {
if self.content.len() != 1 {
bail!("expected one content entry, found {}", self.content.len());
}
if let Some(mt) = self.content.get("application/json") {
Ok(mt.clone())
} else {
bail!(
"could not find application/json, only found {}",
self.content.keys().next().unwrap()
);
}
}
fn is_binary(&self) -> Result<bool> {
if self.content.len() != 1 {
bail!("expected one content entry, found {}", self.content.len());
}
if let Some(mt) = self.content.get("application/octet-stream") {
if !mt.encoding.is_empty() {
bail!("XXX encoding");
}
if let Some(s) = &mt.schema {
use openapiv3::{
SchemaKind, StringFormat, Type,
VariantOrUnknownOrEmpty::Item,
};
let s = s.item()?;
if s.schema_data.nullable {
bail!("XXX nullable binary?");
}
if s.schema_data.default.is_some() {
bail!("XXX default binary?");
}
if s.schema_data.discriminator.is_some() {
bail!("XXX binary discriminator?");
}
match &s.schema_kind {
SchemaKind::Type(Type::String(st)) => {
if st.min_length.is_some() || st.max_length.is_some() {
bail!("binary min/max length");
}
if !matches!(st.format, Item(StringFormat::Binary)) {
bail!(
"expected binary format string, got {:?}",
st.format
);
}
if st.pattern.is_some() {
bail!("XXX pattern");
}
if !st.enumeration.is_empty() {
bail!("XXX enumeration");
}
return Ok(true);
}
x => {
bail!("XXX schemakind type {:?}", x);
}
}
} else {
bail!("binary thing had no schema?");
}
}
Ok(false)
}
}
trait ReferenceOrExt<T> {
fn item(&self) -> Result<&T>;
}
impl<T> ReferenceOrExt<T> for openapiv3::ReferenceOr<T> {
fn item(&self) -> Result<&T> {
match self {
openapiv3::ReferenceOr::Item(i) => Ok(i),
openapiv3::ReferenceOr::Reference { reference: _ } => {
bail!("reference not supported here");
}
}
}
}
#[allow(dead_code)]
#[derive(Debug, PartialEq)]
enum TypeDetails {
Unknown,
Basic,
Array(TypeId),
Optional(TypeId),
Object(HashMap<String, TypeId>),
}
#[derive(Debug)]
struct TypeEntry {
id: TypeId,
name: Option<String>,
details: TypeDetails,
}
#[derive(Debug, Eq, Clone)]
struct TypeId(u64);
impl PartialOrd for TypeId {
fn partial_cmp(&self, other: &TypeId) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for TypeId {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.0.cmp(&other.0)
}
}
impl PartialEq for TypeId {
fn eq(&self, other: &TypeId) -> bool {
self.0 == other.0
}
}
#[derive(Debug)]
struct TypeSpace {
next_id: u64,
/*
* Object types generally have a useful name, which we would like to match
* with anywhere that name appears in the definition document. Many other
* types, though, do not; e.g., an array of strings is just going to become
* Vec<String> without necesssarily having a useful distinct type name.
*/
name_to_id: BTreeMap<String, TypeId>,
id_to_entry: BTreeMap<TypeId, TypeEntry>,
ref_to_id: BTreeMap<String, TypeId>,
import_chrono: bool,
}
impl TypeSpace {
fn new() -> TypeSpace {
TypeSpace {
next_id: 1,
name_to_id: BTreeMap::new(),
id_to_entry: BTreeMap::new(),
ref_to_id: BTreeMap::new(),
import_chrono: false,
}
}
/**
* Emit a human-readable diagnostic description for this type ID.
*/
fn describe(&self, tid: &TypeId) -> String {
if let Some(te) = self.id_to_entry.get(&tid) {
match &te.details {
TypeDetails::Basic => {
if let Some(n) = &te.name {
n.to_string()
} else {
format!("[BASIC {} !NONAME?]", tid.0)
}
}
TypeDetails::Array(itid) => {
if let Some(ite) = self.id_to_entry.get(&itid) {
if let Some(n) = &ite.name {
return format!("array of {} <{}>", n, itid.0);
}
}
/*
* If there is no name attached, we should try a
* recursive describe.
*/
format!("array of {}", self.describe(itid))
}
TypeDetails::Optional(itid) => {
if let Some(ite) = self.id_to_entry.get(&itid) {
if let Some(n) = &ite.name {
return format!("option of {} <{}>", n, itid.0);
}
}
/*
* If there is no name attached, we should try a
* recursive describe.
*/
format!("option of {}", self.describe(itid))
}
TypeDetails::Object(_) => {
if let Some(n) = &te.name {
format!("object {}", n)
} else {
format!("[OBJECT {} !NONAME?]", tid.0)
}
}
TypeDetails::Unknown => {
format!("[UNKNOWN {}]", tid.0)
}
}
} else {
format!("[UNMAPPED {}]", tid.0)
}
}
fn render_type(&self, tid: &TypeId) -> Result<String> {
if let Some(te) = self.id_to_entry.get(&tid) {
match &te.details {
TypeDetails::Basic => {
if let Some(n) = &te.name {
Ok(n.to_string())
} else {
bail!("basic type {:?} does not have a name?", tid);
}
}
TypeDetails::Array(itid) => {
if let Some(ite) = self.id_to_entry.get(&itid) {
if let Some(n) = &ite.name {
Ok(format!("Vec<{}>", n))
} else {
bail!("array inner type {:?} no name?", ite);
}
} else {
bail!("array inner type {:?} missing?", itid);
}
}
TypeDetails::Optional(itid) => {
if let Some(ite) = self.id_to_entry.get(&itid) {
if let Some(n) = &ite.name {
Ok(format!("Option<{}>", n))
} else {
bail!("optional inner type {:?} no name?", ite);
}
} else {
bail!("optional inner type {:?} missing?", itid);
}
}
TypeDetails::Object(_) => {
if let Some(n) = &te.name {
Ok(n.to_string())
} else {
bail!("object type {:?} does not have a name?", tid);
}
}
TypeDetails::Unknown => {
bail!("type {:?} is unknown", tid);
}
}
} else {
bail!("could not resolve type ID {:?}", tid);
}
}
fn assign(&mut self) -> TypeId {
let id = TypeId(self.next_id);
self.next_id += 1;
id
}
fn id_for_name(&mut self, name: &str) -> TypeId {
let id = if let Some(id) = self.name_to_id.get(name) {
id.clone()
} else {
let id = self.assign();
self.name_to_id.insert(name.to_string(), id.clone());
id
};
id
}
fn id_for_optional(&mut self, want: &TypeId) -> TypeId {
for (oid, oent) in self.id_to_entry.iter() {
match &oent.details {
TypeDetails::Optional(id) if id == want => return oid.clone(),
_ => continue,
}
}
let oid = self.assign();
self.id_to_entry.insert(
oid.clone(),
TypeEntry {
id: oid.clone(),
name: None,
details: TypeDetails::Optional(want.clone()),
},
);
oid
}
fn prepop_reference(&mut self, name: &str, r: &str) -> Result<()> {
let id = self.id_for_name(name);
if let Some(rid) = self.ref_to_id.get(r) {
if rid != &id {
bail!(
"duplicate ref {:?}, name, {:?}, id {:?}, rid {:?}",
r,
name,
id,
rid
);
}
} else {
self.ref_to_id.insert(r.to_string(), id);
}
Ok(())
}
fn select_ref(&mut self, _name: Option<&str>, r: &str) -> Result<TypeId> {
/*
* As this is a reference, all we can do for now is determine
* the type ID.
*/
Ok(if let Some(id) = self.ref_to_id.get(r) {
id.clone()
} else {
let id = self.assign();
self.ref_to_id.insert(r.to_string(), id.clone());
id
})
}
fn select_schema(
&mut self,
name: Option<&str>,
s: &openapiv3::Schema,
) -> Result<TypeId> {
let (name, details) = match &s.schema_kind {
openapiv3::SchemaKind::Type(t) => match t {
openapiv3::Type::Array(at) => {
/*
* Determine the type of item that will be in this array:
*/
let itid = self.select_box(None, &at.items)?;
(None, TypeDetails::Array(itid))
}
openapiv3::Type::Object(o) => {
/*
* Object types must have a consistent name.
*/
let name = match (name, s.schema_data.title.as_deref()) {
(Some(n), None) => n.to_string(),
(None, Some(t)) => t.to_string(),
(Some(n), Some(t)) if n == t => n.to_string(),
(Some(n), Some(t)) => {
bail!("names {} and {} conflict", n, t)
}
(None, None) => bail!("types need a name? {:?}", s),
};
let mut omap = HashMap::new();
for (n, rb) in o.properties.iter() {
let itid = self.select_box(None, &rb)?;
if o.required.contains(n) {
omap.insert(n.to_string(), itid);
} else {
/*
* This is an optional member.
*/
omap.insert(
n.to_string(),
self.id_for_optional(&itid),
);
}
}
(Some(name), TypeDetails::Object(omap))
}
openapiv3::Type::String(st) => {
use openapiv3::{
StringFormat::DateTime,
VariantOrUnknownOrEmpty::{Empty, Item},
};
match &st.format {
Item(DateTime) => {
self.import_chrono = true;
(
Some("DateTime<Utc>".to_string()),
TypeDetails::Basic,
)
}
Empty => {
(Some("String".to_string()), TypeDetails::Basic)
}
x => {
bail!("XXX string format {:?}", x);
}
}
}
openapiv3::Type::Boolean {} => {
(Some("bool".to_string()), TypeDetails::Basic)
}
openapiv3::Type::Number(_) => {
/*
* XXX
*/
(Some("f64".to_string()), TypeDetails::Basic)
}
openapiv3::Type::Integer(_) => {
/*
* XXX
*/
(Some("i64".to_string()), TypeDetails::Basic)
}
},
x => {
bail!("unhandled schema kind: {:?}", x);
}
};
if let Some(name) = &name {
/*
* First, determine what ID we will use to identify this named type.
*/
let id = self.id_for_name(name.as_str());
/*
* If there is already an entry for this type ID, ensure that it
* matches the entry we have constructed. If there is not yet an
* entry, we can just keep this one.
*/
if let Some(et) = self.id_to_entry.get(&id) {
if et.details != details {
bail!("{:?} != {:?}", et.details, details);
}
} else {
self.id_to_entry.insert(
id.clone(),
TypeEntry {
id: id.clone(),
name: Some(name.clone()),
details,
},
);
}
Ok(id)
} else {
/*
* If this type has no name, look for an existing unnamed type with
* the same shape.
*/
for (tid, te) in self.id_to_entry.iter() {
if te.name.is_none() && te.details == details {
return Ok(tid.clone());
}
}
/*
* Otherwise, insert a new entry.
*/
let tid = self.assign();
self.id_to_entry.insert(
tid.clone(),
TypeEntry {
id: tid.clone(),
name: None,
details,
},
);
Ok(tid)
}
}
fn select(
&mut self,
name: Option<&str>,
s: &openapiv3::ReferenceOr<openapiv3::Schema>,
) -> Result<TypeId> {
match s {
openapiv3::ReferenceOr::Reference { reference } => {
self.select_ref(name, reference.as_str())
}
openapiv3::ReferenceOr::Item(s) => self.select_schema(name, s),
}
}
fn select_box(
&mut self,
name: Option<&str>,
s: &openapiv3::ReferenceOr<Box<openapiv3::Schema>>,
) -> Result<TypeId> {
match s {
openapiv3::ReferenceOr::Reference { reference } => {
self.select_ref(name, reference.as_str())
}
openapiv3::ReferenceOr::Item(s) => {
self.select_schema(name, s.as_ref())
}
}
}
}
fn gen(api: &OpenAPI, ts: &mut TypeSpace) -> Result<String> {
let mut out = String::new();
let mut a = |s: &str| {
out.push_str(s);
out.push('\n');
};
/*
* Deal with any dependencies we require to produce this client.
*/
if ts.import_chrono {
a("");
a("use chrono::prelude::*;");
a("");
}
/*
* Declare named types we know about:
*/
for te in ts.id_to_entry.values() {
match &te.details {
TypeDetails::Object(omap) => {
a(&format!("pub struct {} {{", te.name.as_deref().unwrap()));
for (name, tid) in omap.iter() {
a(&format!(" pub {}: {},", name, ts.render_type(tid)?));
}
a("}");
a("");
}
TypeDetails::Basic => {}
TypeDetails::Unknown => {}
TypeDetails::Array(_) => {}
TypeDetails::Optional(_) => {}
}
}
/*
* Declare the client object:
*/
a("pub struct Client {");
a("}");
a("");
a("impl Client {");
/*
* Generate a function for each Operation.
*
* XXX We should probably be producing an intermediate object for each of
* these, which can link in to the type space, instead of doing this inline
* here.
*/
for (pn, p) in api.paths.iter() {
let op = p.item()?;
let mut gen = |p: &str,
m: &str,
o: Option<&openapiv3::Operation>|
-> Result<()> {
let o = if let Some(o) = o {
o
} else {
return Ok(());
};
let oid = o.operation_id.as_deref().unwrap();
a(" /*");
a(&format!(" * {}: {} {}", oid, m, p));
a(" */");
let mut bounds: Vec<String> = Vec::new();
let body_param = if let Some(b) = &o.request_body {
let b = b.item()?;
if b.is_binary()? {
bounds.push("B: Into<reqwest::Body>".to_string());
Some("B".to_string())
} else {
let mt = b.content_json()?;
if !mt.encoding.is_empty() {
bail!("media type encoding not empty: {:#?}", mt);
}
if let Some(s) = &mt.schema {
let tid = ts.select(None, s)?;
Some(format!("&{}", ts.render_type(&tid)?))
} else {
bail!("media type encoding, no schema: {:#?}", mt);
}
}
} else {
None
};
if bounds.is_empty() {
a(&format!(" pub async fn {}(", oid));
} else {
a(&format!(" pub async fn {}<{}>(", oid, bounds.join(", ")));
}
a(" &self,");
for par in o.parameters.iter() {
match par.item()? {
openapiv3::Parameter::Path {
parameter_data,
style: openapiv3::PathStyle::Simple,
} => {
/*
* XXX Parameter types should probably go through
* the type space...
*/
let nam = &parameter_data.name;
let typ = parameter_data.render_type()?;
a(&format!(" {}: {},", nam, typ));
}
openapiv3::Parameter::Query {
parameter_data,
allow_reserved: _,
style: openapiv3::QueryStyle::Form,
allow_empty_value,
} => {
if let Some(aev) = allow_empty_value {
if *aev {
bail!("allow empty value is a no go");
}
}
/*
* XXX Parameter types should probably go through
* the type space...
*/
let nam = &parameter_data.name;
let typ = parameter_data.render_type()?;
a(&format!(" {}: {},", nam, typ));
}
x => bail!("unhandled parameter type: {:#?}", x),
}
}
if let Some(bp) = &body_param {
a(&format!(" body: {},", bp));
}
// println!("{:#?}", o.responses);
if o.responses.responses.len() == 1 {
let only = o.responses.responses.iter().next().unwrap();
match only.0 {
openapiv3::StatusCode::Code(n) => {
if *n < 200 || *n > 299 {
bail!("code? {:#?}", only);
}
}
_ => bail!("code? {:#?}", only),
}
let i = only.1.item()?;
if !i.headers.is_empty() {
bail!("no response headers for now");
}
if !i.links.is_empty() {
bail!("no response links for now");
}
/*
* XXX ignoring extensions.
*/
/*
* Look at the response content. For now, support a single
* JSON-formatted response.
*/
match (i.content.len(), i.content.get("application/json")) {
(0, _) => {
a(" ) -> Result<()> {");
}
(1, Some(mt)) => {
if !mt.encoding.is_empty() {
bail!("media type encoding not empty: {:#?}", mt);
}
if let Some(s) = &mt.schema {
let tid = ts.select(None, s)?;
a(&format!(
" ) -> Result<{}> {{",
ts.render_type(&tid)?
));
} else {
bail!("media type encoding, no schema: {:#?}", mt);
}
}
(1, None) => {
bail!("response content not JSON: {:#?}", i.content);
}
(_, _) => {
bail!("too many response contents: {:#?}", i.content);
}
}
} else {
bail!("responses? {:#?}", o.responses);
}
a(" unimplemented!();");
a(" }");
a("");
Ok(())
};
gen(pn.as_str(), "GET", op.get.as_ref())?;
gen(pn.as_str(), "PUT", op.put.as_ref())?;
gen(pn.as_str(), "POST", op.post.as_ref())?;
gen(pn.as_str(), "DELETE", op.delete.as_ref())?;
gen(pn.as_str(), "OPTIONS", op.options.as_ref())?;
gen(pn.as_str(), "HEAD", op.head.as_ref())?;
gen(pn.as_str(), "PATCH", op.patch.as_ref())?;
gen(pn.as_str(), "TRACE", op.trace.as_ref())?;
}
a("}");
Ok(out)
}
fn main() -> Result<()> {
let mut opts = getopts::Options::new();
opts.parsing_style(getopts::ParsingStyle::StopAtFirstFree);
opts.reqopt("i", "", "OpenAPI definition document (JSON)", "INPUT");
opts.reqopt("o", "", "Generated Rust output file", "OUTPUT");
let args = match opts.parse(std::env::args().skip(1)) {
Ok(args) => {
if !args.free.is_empty() {
eprintln!("{}", opts.usage("progenitor"));
bail!("unexpected positional arguments");
}
args
}
Err(e) => {
eprintln!("{}", opts.usage("progenitor"));
bail!(e);
}
};
let api = load_api(&args.opt_str("i").unwrap())?;
let mut ts = TypeSpace::new();
if let Some(components) = &api.components {
/*
* First, grant each expected reference a type ID.
*/
for n in components.schemas.keys() {
println!("PREPOP {}:", n);
ts.prepop_reference(n, &format!("#/components/schemas/{}", n))?;
}
println!();
for (i, (sn, s)) in components.schemas.iter().enumerate() {
println!("SCHEMA {}/{}: {}", i + 1, components.schemas.len(), sn);
let id = ts.select(Some(sn.as_str()), s)?;
println!(" -> {:?}", id);
/*
* Each "components.schemas" entry needs an established reference
* for resolution in other parts of the document.
*/
println!();
}
}
let fail = match gen(&api, &mut ts) {
Ok(out) => {
save(&args.opt_str("o").unwrap(), out.as_str())?;
false
}
Err(e) => {
println!("gen fail: {:?}", e);
true
}
};
println!("-----------------------------------------------------");
println!(" TYPE SPACE");
println!("-----------------------------------------------------");
for te in ts.id_to_entry.values() {
let n = ts.describe(&te.id);
println!("{:>4} {}", te.id.0, n);
}
println!("-----------------------------------------------------");
println!();
if fail {
bail!("generation experienced errors");
}
Ok(())
}