oas3-gen 0.17.0

A rust type generator for OpenAPI v3.1.x specification.
use anyhow::anyhow;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};

use crate::{
  generator::ast::{OperationBody, OperationInfo, ParameterLocation, TypeRef},
  reserved::header_const_name,
};

struct TypeInfo {
  request_ident: syn::Ident,
  response_enum: Option<syn::Type>,
  response_type: Option<syn::Type>,
}

struct MethodComponents {
  doc_attrs: Vec<TokenStream>,
  builder_init: TokenStream,
  header_statements: Vec<TokenStream>,
  body_statement: TokenStream,
  return_ty: TokenStream,
  response_handling: TokenStream,
}

pub(super) fn build_method_tokens(operation: &OperationInfo) -> anyhow::Result<TokenStream> {
  let type_info = extract_type_info(operation)?;
  let method_name = format_ident!("{}", operation.stable_id);

  let (return_ty, response_handling) = build_response_handling(&type_info);
  let components = MethodComponents {
    doc_attrs: build_doc_attributes(operation),
    builder_init: build_http_method_init(&operation.method),
    header_statements: build_header_statements(operation),
    body_statement: build_body_statement(operation),
    return_ty,
    response_handling,
  };

  Ok(assemble_method_tokens(
    &method_name,
    &type_info.request_ident,
    &components,
  ))
}

fn extract_type_info(operation: &OperationInfo) -> anyhow::Result<TypeInfo> {
  let request_type_name = operation.request_type.as_ref().ok_or_else(|| {
    anyhow!(
      "operation `{}` is missing request type information",
      operation.operation_id
    )
  })?;
  let request_ident = format_ident!("{}", request_type_name);

  let response_enum = operation
    .response_enum
    .as_ref()
    .map(|name| parse_type(name))
    .transpose()?;
  let response_type = operation
    .response_type
    .as_ref()
    .map(|name| parse_type(name))
    .transpose()?;

  Ok(TypeInfo {
    request_ident,
    response_enum,
    response_type,
  })
}

fn build_http_method_init(method: &str) -> TokenStream {
  let method_lower = method.to_ascii_lowercase();
  match method_lower.as_str() {
    "get" => quote! { self.client.get(url) },
    "post" => quote! { self.client.post(url) },
    "put" => quote! { self.client.put(url) },
    "delete" => quote! { self.client.delete(url) },
    "patch" => quote! { self.client.patch(url) },
    "head" => quote! { self.client.head(url) },
    _ => {
      let method_upper = syn::Ident::new(&method_lower.to_ascii_uppercase(), proc_macro2::Span::call_site());
      quote! { self.client.request(reqwest::Method::#method_upper, url) }
    }
  }
}

fn build_doc_attributes(operation: &OperationInfo) -> Vec<TokenStream> {
  let mut doc_attrs = Vec::new();
  if let Some(summary) = &operation.summary {
    for line in summary.lines() {
      let trimmed = line.trim();
      if !trimmed.is_empty() {
        let lit = syn::LitStr::new(trimmed, proc_macro2::Span::call_site());
        doc_attrs.push(quote! { #[doc = #lit] });
      }
    }
  }
  let signature_doc = format!("{} {}", operation.method.to_uppercase(), operation.path);
  let signature_lit = syn::LitStr::new(&signature_doc, proc_macro2::Span::call_site());
  doc_attrs.push(quote! { #[doc = #signature_lit] });

  doc_attrs
}

fn build_header_statements(operation: &OperationInfo) -> Vec<TokenStream> {
  operation
    .parameters
    .iter()
    .filter(|param| matches!(param.location, ParameterLocation::Header))
    .map(|param| {
      let const_name = header_const_name(&param.original_name);
      let const_ident = format_ident!("{const_name}");
      let field_ident = format_ident!("{}", param.rust_field);

      let value_conversion = build_header_value_conversion(&param.rust_type, &field_ident, param.required);

      if param.required {
        quote! {
          {
            #value_conversion
            req_builder = req_builder.header(#const_ident, header_value);
          }
        }
      } else {
        quote! {
          if let Some(value) = request.#field_ident.as_ref() {
            #value_conversion
            req_builder = req_builder.header(#const_ident, header_value);
          }
        }
      }
    })
    .collect()
}

fn build_header_value_conversion(rust_type: &TypeRef, field_ident: &syn::Ident, required: bool) -> TokenStream {
  let value_expr = if required {
    quote! { request.#field_ident }
  } else {
    quote! { value }
  };

  if is_string_type(rust_type) {
    quote! {
      let header_value = HeaderValue::from_str(#value_expr.as_str())?;
    }
  } else if is_primitive_type(rust_type) {
    quote! {
      let header_value = HeaderValue::from_str(&(#value_expr).to_string())?;
    }
  } else {
    quote! {
      let header_value = HeaderValue::from_str(&serde_plain::to_string(&#value_expr)?)?;
    }
  }
}

fn is_string_type(type_ref: &TypeRef) -> bool {
  matches!(type_ref.base_type, crate::generator::ast::RustPrimitive::String) && !type_ref.is_array
}

fn is_primitive_type(type_ref: &TypeRef) -> bool {
  use crate::generator::ast::RustPrimitive;
  matches!(
    type_ref.base_type,
    RustPrimitive::I8
      | RustPrimitive::I16
      | RustPrimitive::I32
      | RustPrimitive::I64
      | RustPrimitive::I128
      | RustPrimitive::Isize
      | RustPrimitive::U8
      | RustPrimitive::U16
      | RustPrimitive::U32
      | RustPrimitive::U64
      | RustPrimitive::U128
      | RustPrimitive::Usize
      | RustPrimitive::F32
      | RustPrimitive::F64
      | RustPrimitive::Bool
  ) && !type_ref.is_array
}

fn build_body_statement(operation: &OperationInfo) -> TokenStream {
  operation
    .body
    .as_ref()
    .map(build_body_for_content_type)
    .unwrap_or_default()
}

fn build_body_for_content_type(body: &OperationBody) -> TokenStream {
  let field_ident = format_ident!("{}", body.field_name);
  let content_type = body
    .content_type
    .as_deref()
    .unwrap_or("application/json")
    .to_ascii_lowercase();

  if content_type.contains("json") {
    build_json_body(&field_ident, body.optional)
  } else if content_type.contains("x-www-form-urlencoded") {
    build_form_body(&field_ident, body.optional)
  } else if content_type.contains("multipart") {
    build_multipart_body(&field_ident, body.optional)
  } else if content_type.contains("text/plain") || content_type.contains("text/html") {
    build_text_body(&field_ident, body.optional)
  } else if content_type.contains("octet-stream")
    || (content_type.starts_with("application/") && !content_type.contains("json"))
  {
    build_binary_body(&field_ident, body.optional)
  } else if content_type.contains("xml") {
    build_xml_body(&field_ident, body.optional)
  } else {
    build_fallback_body(&field_ident, body.optional)
  }
}

fn build_json_body(field_ident: &syn::Ident, optional: bool) -> TokenStream {
  if optional {
    quote! {
      if let Some(body) = request.#field_ident.as_ref() {
        req_builder = req_builder.json(body);
      }
    }
  } else {
    quote! {
      req_builder = req_builder.json(&request.#field_ident);
    }
  }
}

fn build_form_body(field_ident: &syn::Ident, optional: bool) -> TokenStream {
  if optional {
    quote! {
      if let Some(body) = request.#field_ident.as_ref() {
        req_builder = req_builder.form(body);
      }
    }
  } else {
    quote! {
      req_builder = req_builder.form(&request.#field_ident);
    }
  }
}

fn build_multipart_body(field_ident: &syn::Ident, optional: bool) -> TokenStream {
  let multipart_logic = quote! {
    let json_value = serde_json::to_value(body)?;
    let mut form = reqwest::multipart::Form::new();
    if let serde_json::Value::Object(map) = json_value {
      for (key, value) in map {
        let text_value = match value {
          serde_json::Value::String(s) => s,
          serde_json::Value::Number(n) => n.to_string(),
          serde_json::Value::Bool(b) => b.to_string(),
          serde_json::Value::Null => continue,
          other => serde_json::to_string(&other)?,
        };
        form = form.text(key, text_value);
      }
    }
    req_builder = req_builder.multipart(form);
  };

  if optional {
    quote! {
      if let Some(body) = request.#field_ident.as_ref() {
        #multipart_logic
      }
    }
  } else {
    quote! {
      let body = &request.#field_ident;
      #multipart_logic
    }
  }
}

fn build_text_body(field_ident: &syn::Ident, optional: bool) -> TokenStream {
  if optional {
    quote! {
      if let Some(body) = request.#field_ident.as_ref() {
        req_builder = req_builder.body(body.to_string());
      }
    }
  } else {
    quote! {
      req_builder = req_builder.body(request.#field_ident.to_string());
    }
  }
}

fn build_binary_body(field_ident: &syn::Ident, optional: bool) -> TokenStream {
  if optional {
    quote! {
      if let Some(body) = request.#field_ident.as_ref() {
        req_builder = req_builder.body(body.clone());
      }
    }
  } else {
    quote! {
      req_builder = req_builder.body(request.#field_ident.clone());
    }
  }
}

fn build_xml_body(field_ident: &syn::Ident, optional: bool) -> TokenStream {
  if optional {
    quote! {
      if let Some(body) = request.#field_ident.as_ref() {
        let xml_string = body.to_string();
        req_builder = req_builder
          .header("Content-Type", "application/xml")
          .body(xml_string);
      }
    }
  } else {
    quote! {
      let xml_string = request.#field_ident.to_string();
      req_builder = req_builder
        .header("Content-Type", "application/xml")
        .body(xml_string);
    }
  }
}

fn build_fallback_body(field_ident: &syn::Ident, optional: bool) -> TokenStream {
  if optional {
    quote! {
      if let Some(body) = request.#field_ident.as_ref() {
        req_builder = req_builder.body(serde_json::to_vec(body)?);
      }
    }
  } else {
    quote! {
      req_builder = req_builder.body(serde_json::to_vec(&request.#field_ident)?);
    }
  }
}

fn build_response_handling(type_info: &TypeInfo) -> (TokenStream, TokenStream) {
  if let Some(response_enum) = &type_info.response_enum {
    let request_ident = &type_info.request_ident;
    return (
      quote! { #response_enum },
      quote! {
        let parsed = #request_ident::parse_response(response).await?;
        Ok(parsed)
      },
    );
  }

  if let Some(response_ty) = &type_info.response_type {
    return (
      quote! { #response_ty },
      quote! {
        let parsed = response.json::<#response_ty>().await?;
        Ok(parsed)
      },
    );
  }

  (quote! { reqwest::Response }, quote! { Ok(response) })
}

fn assemble_method_tokens(
  method_name: &syn::Ident,
  request_ident: &syn::Ident,
  components: &MethodComponents,
) -> TokenStream {
  let doc_attrs = &components.doc_attrs;
  let builder_init = &components.builder_init;
  let header_statements = &components.header_statements;
  let body_statement = &components.body_statement;
  let return_ty = &components.return_ty;
  let response_handling = &components.response_handling;

  quote! {
    #(#doc_attrs)*
    pub async fn #method_name(&self, request: #request_ident) -> anyhow::Result<#return_ty> {
      request.validate().context("parameter validation")?;
      let url = self
        .base_url
        .join(&request.render_path()?)
        .context("constructing request url")?;
      let mut req_builder = #builder_init;
      #(#header_statements)*
      #body_statement
      let response = req_builder.send().await?;
      #response_handling
    }
  }
}

fn parse_type(type_name: &str) -> anyhow::Result<syn::Type> {
  syn::parse_str(type_name).map_err(|err| anyhow!("failed to parse type `{type_name}`: {err}"))
}