Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Proof of Concept] SDK code generation and plugins #539

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
787 changes: 657 additions & 130 deletions Cargo.lock

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions crates/cloudtruth-sdk-codegen/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "cloudtruth-sdk-codegen"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
color-eyre = "0.6.2"
http = "0.2.9"
indexmap = "1"
openapiv3 = { version = "1" }
proc-macro2 = "1.0.63"
quote = "1.0.29"
serde_json = "1.0"
syn = { version = "2.0.23", features = ["full"] }
rfc6570-level-2 = "1.2.0"
dyn-clone = "1.0.11"
5 changes: 5 additions & 0 deletions crates/cloudtruth-sdk-codegen/src/api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
mod operation;
mod spec;

pub use operation::ApiOperation;
pub use spec::ApiSpec;
86 changes: 86 additions & 0 deletions crates/cloudtruth-sdk-codegen/src/api/operation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use std::rc::Rc;

use color_eyre::{eyre::eyre, Result};
use indexmap::IndexMap;
use openapiv3::{Operation, Parameter, RequestBody, Responses};

use rfc6570_level_2::UriTemplate;

#[derive(Debug, Clone)]
pub struct ApiOperation {
path_template: UriTemplate,
http_method: http::Method,
summary: Option<Rc<str>>,
description: Option<Rc<str>>,
operation_id: Option<Rc<str>>,
tags: Vec<Rc<str>>,
deprecated: bool,
request_body: Option<RequestBody>,
parameters: Vec<Parameter>,
responses: Responses,
security: Option<Vec<IndexMap<String, Vec<String>>>>,
}

impl ApiOperation {
pub fn from_openapi(path: &str, method: &str, op: Operation) -> Result<ApiOperation> {
let Operation {
description,
summary,
operation_id,
tags,
request_body,
parameters,
responses,
deprecated,
security,
..
} = op;
let request_body = request_body.map(|b| b.into_item().unwrap());
let parameters: Vec<Parameter> = parameters
.into_iter()
.map(|p| p.into_item().unwrap())
.collect();

Ok(ApiOperation {
path_template: UriTemplate::new(path).map_err(|err| eyre!(Box::new(err)))?, // convert anyhow to eyre
http_method: method.parse()?,
description: description.map(|s| Rc::from(s.as_str())),
summary: summary.map(|s| Rc::from(s.as_str())),
operation_id: operation_id.map(|s| Rc::from(s.as_str())),
tags: tags.into_iter().map(|s| Rc::from(s.as_str())).collect(),
deprecated,
request_body,
parameters,
responses,
security,
})
}

pub fn uri(&self) -> &str {
self.path_template.uri()
}

pub fn http_method(&self) -> &http::Method {
&self.http_method
}

pub fn summary(&self) -> Option<&Rc<str>> {
self.summary.as_ref()
}

pub fn description(&self) -> Option<&Rc<str>> {
self.description.as_ref()
}

pub fn operation_id(&self) -> Option<&Rc<str>> {
self.operation_id.as_ref()
}

pub fn tags(&self) -> &[Rc<str>] {
self.tags.as_ref()
}

pub fn deprecated(&self) -> bool {
self.deprecated
}
}
33 changes: 33 additions & 0 deletions crates/cloudtruth-sdk-codegen/src/api/spec.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
use std::rc::Rc;

use color_eyre::Result;
use openapiv3::OpenAPI;

use crate::api::ApiOperation;

#[derive(Debug, Clone)]
pub struct ApiSpec {
operations: Vec<Rc<ApiOperation>>,
}

impl ApiSpec {
pub fn new(open_api: OpenAPI) -> Result<Self> {
let mut operations = open_api
.operations()
.filter(|(path, _, _)| path.starts_with("/api/v1/integrations/azure/key_vault/"))
.map(|(path, method, op)| {
Ok(Rc::new(ApiOperation::from_openapi(
path,
method,
op.clone(),
)?))
})
.collect::<Result<Vec<Rc<ApiOperation>>>>()?;
operations.sort_by(|a, b| a.uri().cmp(b.uri()));
Ok(Self { operations })
}

pub fn operations(&self) -> &[Rc<ApiOperation>] {
&self.operations
}
}
124 changes: 124 additions & 0 deletions crates/cloudtruth-sdk-codegen/src/generator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
use std::rc::Rc;

use syn::parse_quote;

use crate::{
api::ApiSpec,
sdk::{
methods::{
SdkApiMethod, SdkChildConstructor, SdkRootConstructor, SdkStaticRootConstructor,
},
SdkObject,
},
};

pub struct SdkGenerator {
spec: ApiSpec,
root_prefix: String,
}

impl SdkGenerator {
pub fn new(spec: ApiSpec) -> Self {
Self {
spec,
root_prefix: String::new(),
}
}

pub fn spec(&self) -> &ApiSpec {
&self.spec
}

pub fn root_prefix(&mut self, prefix: impl Into<String>) -> &mut Self {
self.root_prefix = prefix.into();
self
}

pub fn build_objects(&self) -> Vec<Rc<SdkObject>> {
// iterator over API operations from the spec (assumed to be sorted)
let operations = self.spec.operations();
// list of objects that we are building
let mut objects = Vec::with_capacity(operations.len());
// a stack of ancestors from pervious iterations
let mut ancestors = Vec::with_capacity(operations.len());
// create root SDK object
let mut root = SdkObject::new("CloudtruthSdk", None);
root.add_field("client", parse_quote![Arc<Client>]);
root.add_method(SdkRootConstructor::new(&root));
root.add_method(SdkStaticRootConstructor::new());
// add root to ancestor stack
ancestors.push((self.root_prefix.as_ref(), root));

for op in operations.iter() {
// println!();
let uri = op.uri().trim_end_matches('/');
// let method = op.http_method();
// println!("{method} {uri}");

// find the ancestor of current path in the stack and get the descendant path segments
let descendant_path = loop {
match ancestors.last() {
Some((ancestor_prefix, _)) => match uri.strip_prefix(ancestor_prefix) {
// found ancestor, return the descendant path
Some(descendant_path) => break descendant_path,
// not an ancestor, pop from stack and append to our output list
None => objects.push(Rc::new(ancestors.pop().unwrap().1)),
},
// no valid ancestor (unexpected behavior)
None => panic!("No ancestor found for {uri}"),
}
};
// println!("{descendant_path:#}");
for child_segment in descendant_path.trim_start_matches('/').split('/') {
if child_segment.is_empty() {
continue;
}
let is_path_var = child_segment.starts_with('{') && child_segment.ends_with('}');
let name = if is_path_var {
child_segment
.chars()
.filter(|c| *c == '_' || c.is_alphanumeric())
.collect::<String>()
} else {
child_segment.to_string()
};
// append this path segment to current prefix
let segment_start = child_segment.as_ptr() as usize - uri.as_ptr() as usize;
let segment_end = segment_start + child_segment.len();
let path = &uri[..segment_end];
// get parent object
let size = ancestors.len();
let parent_object = ancestors.get_mut(size - 1).map(|(_, obj)| obj);
// create SDK obect for this node
let mut current_object = SdkObject::new(
path.strip_prefix(&self.root_prefix).unwrap(),
parent_object.as_deref(),
);
// attach struct field for the path variable
if is_path_var {
current_object.add_field(&name, parse_quote![Arc<str>]);
}

// attach getter method to parent object
if let Some(parent_object) = parent_object {
let mut method = SdkChildConstructor::new(parent_object, &current_object);
if is_path_var {
method.add_arg(&name, parse_quote![impl Into<Arc<str>>]);
}
parent_object.add_method(method);
}

// add to ancestors stack
ancestors.push((path, current_object));
}
let size = ancestors.len();
if let Some((_, last_object)) = ancestors.get_mut(size - 1) {
last_object.add_method(SdkApiMethod::new(uri, op.clone()));
}
}

// add any remaining ancestors in stack to output list
objects.extend(ancestors.into_iter().map(|(_, ancestor)| Rc::new(ancestor)));
objects
}
}
47 changes: 47 additions & 0 deletions crates/cloudtruth-sdk-codegen/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
pub mod api;
pub mod generator;
pub mod module;
mod names;
pub mod sdk;

use api::ApiSpec;
use color_eyre::Result;
use generator::SdkGenerator;
use module::SdkModule;
use openapiv3::OpenAPI;
use quote::quote;

macro_rules! sdk_path {
($($path:expr),* $(,)?) => {
std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../cloudtruth-sdk/", $($path),*))
};
}

pub(crate) use sdk_path;

/// Shorthand to quickly create an Ident with call_site macro hygiene
/// Since this is a codegen project, macro hygiene isn't very important for us(?)
macro_rules! ident {
($path:expr) => {
proc_macro2::Ident::new(&*($path), proc_macro2::Span::call_site())
};
}

pub(crate) use ident;

pub fn generate_sdk() -> Result<()> {
let data = include_str!("../../../openapi.json");
let open_api: OpenAPI = serde_json::from_str(data).unwrap();
let spec = ApiSpec::new(open_api)?;
let mut generator = SdkGenerator::new(spec);
generator.root_prefix("/api/v1");
let objects = generator.build_objects();
SdkModule::new(
sdk_path!("src/lib.rs"),
quote! {
#(#objects )*
},
)
.write()?;
Ok(())
}
5 changes: 5 additions & 0 deletions crates/cloudtruth-sdk-codegen/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
use color_eyre::Result;
fn main() -> Result<()> {
color_eyre::install()?;
cloudtruth_sdk_codegen::generate_sdk()
}
45 changes: 45 additions & 0 deletions crates/cloudtruth-sdk-codegen/src/module.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
use std::{borrow::Cow, fs::File, io::Write, path::Path, process::Command};

use color_eyre::{eyre::Context, Result};
use proc_macro2::TokenStream;
use quote::quote;

#[derive(Debug, Clone)]
pub struct SdkModule {
path: Cow<'static, Path>,
tokens: TokenStream,
}

impl SdkModule {
pub fn new(path: impl Into<Cow<'static, Path>>, tokens: impl Into<TokenStream>) -> Self {
SdkModule {
path: path.into(),
tokens: tokens.into(),
}
}

fn imports(&self) -> TokenStream {
quote! {
use std::sync::Arc;
use once_cell::sync::OnceCell;
use reqwest::blocking::Client;
}
}

pub fn write(&self) -> Result<()> {
let imports = self.imports();
let tokens = &self.tokens;
let output = quote! {
#imports
#tokens
};
File::create(self.path.as_ref())
.with_context(move || format!("Could not open: {}", self.path.display()))?
.write_all(output.to_string().as_bytes())?;
Command::new("rustfmt")
.arg(self.path.as_os_str())
.spawn()?
.wait()?;
Ok(())
}
}
28 changes: 28 additions & 0 deletions crates/cloudtruth-sdk-codegen/src/names.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Converts a URL with snake_case path segments to PascalCase.
// Forward slashes and underscores are converted to capitalized names
// braces from path variables are ignored
pub fn convert_url_to_type_name(url: &str) -> String {
let mut pascal = String::new();
let mut capitalize = true;
for ch in url.chars() {
if ch == '{' || ch == '}' {
continue;
} else if ch == '_' || ch == '/' {
capitalize = true;
} else if capitalize {
pascal.push(ch.to_ascii_uppercase());
capitalize = false;
} else {
pascal.push(ch);
}
}
pascal
}

// Remove curly brackets from a path variable of them form "{name}"
// If no brackets, string is returned as-is
pub fn trim_path_var_brackets(s: &str) -> &str {
s.strip_prefix('{')
.and_then(|s| s.strip_suffix('}'))
.unwrap_or(s)
}
Loading