httpageboy 1.0.21

A lightweight library for handling raw HTTP request/response transmission. Good base for APIs. Supports both synchronous and asynchronous programming models.
Documentation
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::Path;

#[derive(Debug, Clone, PartialEq)]
enum Value {
  Bool(bool),
  Null,
  String(String),
  List(Vec<Value>),
  Map(BTreeMap<String, Value>),
}

fn leading_spaces(line: &str) -> usize {
  line.len() - line.trim_start_matches(' ').len()
}

fn strip_comments(text: &str) -> Vec<String> {
  text
    .lines()
    .map(str::trim_end)
    .filter(|line| !line.trim().is_empty() && !line.trim_start().starts_with('#'))
    .map(ToOwned::to_owned)
    .collect()
}

fn parse_scalar(value: &str) -> Value {
  let value = value.trim();
  match value {
    "true" => Value::Bool(true),
    "false" => Value::Bool(false),
    "null" | "~" => Value::Null,
    _ if (value.starts_with('"') && value.ends_with('"')) || (value.starts_with('\'') && value.ends_with('\'')) => {
      Value::String(value[1..value.len() - 1].to_string())
    }
    _ => Value::String(value.to_string()),
  }
}

fn split_key_value(content: &str) -> Result<(String, Option<Value>), String> {
  let Some((key, value)) = content.split_once(':') else {
    return Err(format!("expected key/value pair: {content}"));
  };
  let key = key.trim();
  if key.is_empty() {
    return Err(format!("empty key in: {content}"));
  }
  let value = value.trim();
  if value.is_empty() {
    Ok((key.to_string(), None))
  } else {
    Ok((key.to_string(), Some(parse_scalar(value))))
  }
}

fn parse_block(lines: &[String], index: usize, indent: usize) -> Result<(Value, usize), String> {
  if index >= lines.len() || leading_spaces(&lines[index]) < indent {
    return Ok((Value::Map(BTreeMap::new()), index));
  }
  if lines[index].trim_start().starts_with("- ") {
    parse_list(lines, index, indent)
  } else {
    parse_map(lines, index, indent)
  }
}

fn parse_list(lines: &[String], mut index: usize, indent: usize) -> Result<(Value, usize), String> {
  let mut result = Vec::new();
  while index < lines.len() {
    let line = &lines[index];
    if leading_spaces(line) != indent || !line.trim_start().starts_with("- ") {
      break;
    }

    let content = line.trim_start()[2..].trim();
    index += 1;

    if content.is_empty() {
      let (item, next) = parse_block(lines, index, indent + 2)?;
      result.push(item);
      index = next;
      continue;
    }

    if content.contains(':') {
      let (key, value) = split_key_value(content)?;
      let mut item = BTreeMap::new();
      let value = match value {
        Some(value) => value,
        None => {
          let (nested, next) = parse_block(lines, index, indent + 2)?;
          index = next;
          nested
        }
      };
      item.insert(key, value);

      if index < lines.len()
        && leading_spaces(&lines[index]) == indent + 2
        && !lines[index].trim_start().starts_with("- ")
      {
        let (extra, next) = parse_map(lines, index, indent + 2)?;
        if let Value::Map(extra) = extra {
          item.extend(extra);
        }
        index = next;
      }

      result.push(Value::Map(item));
    } else {
      result.push(parse_scalar(content));
    }
  }
  Ok((Value::List(result), index))
}

fn parse_map(lines: &[String], mut index: usize, indent: usize) -> Result<(Value, usize), String> {
  let mut result = BTreeMap::new();
  while index < lines.len() {
    let line = &lines[index];
    if leading_spaces(line) != indent || line.trim_start().starts_with("- ") {
      break;
    }

    let (key, value) = split_key_value(line.trim())?;
    index += 1;
    let value = match value {
      Some(value) => value,
      None => {
        let (nested, next) = parse_block(lines, index, indent + 2)?;
        index = next;
        nested
      }
    };
    result.insert(key, value);
  }
  Ok((Value::Map(result), index))
}

fn load_routes(path: &Path) -> Result<Value, String> {
  let content = fs::read_to_string(path).map_err(|error| format!("failed to read {}: {error}", path.display()))?;
  let lines = strip_comments(&content);
  let (data, index) = parse_block(&lines, 0, 0)?;
  if index != lines.len() {
    return Err("could not parse all routes.yaml content".to_string());
  }
  Ok(data)
}

fn as_map<'a>(value: &'a Value, name: &str) -> Result<&'a BTreeMap<String, Value>, String> {
  match value {
    Value::Map(map) => Ok(map),
    _ => Err(format!("{name} must be a map")),
  }
}

fn as_list<'a>(value: &'a Value, name: &str) -> Result<&'a [Value], String> {
  match value {
    Value::List(list) => Ok(list),
    _ => Err(format!("{name} must be a list")),
  }
}

fn get_string(map: &BTreeMap<String, Value>, key: &str, fallback: &str) -> String {
  match map.get(key) {
    Some(Value::String(value)) => value.clone(),
    Some(Value::Bool(value)) => value.to_string(),
    Some(Value::Null) | None | Some(Value::Map(_)) | Some(Value::List(_)) => fallback.to_string(),
  }
}

fn get_list<'a>(map: &'a BTreeMap<String, Value>, key: &str) -> Result<&'a [Value], String> {
  match map.get(key) {
    Some(value) => as_list(value, key),
    None => Ok(&[]),
  }
}

fn quote(value: &str) -> String {
  format!("\"{}\"", value.replace('"', "\\\""))
}

fn emit_openapi(data: &Value) -> Result<String, String> {
  let root = as_map(data, "root")?;
  let service = as_map(
    root
      .get("service")
      .ok_or_else(|| "missing service section".to_string())?,
    "service",
  )?;
  let auth = as_map(
    root.get("auth").ok_or_else(|| "missing auth section".to_string())?,
    "auth",
  )?;
  let routes = as_list(
    root.get("routes").ok_or_else(|| "missing routes section".to_string())?,
    "routes",
  )?;

  let name = get_string(service, "name", "httpageboy-api");
  let version = get_string(service, "version", "0.1.0");
  let server = get_string(service, "server", "http://127.0.0.1");
  let headers = get_list(auth, "headers")?;

  let mut out = vec![
    "openapi: 3.0.3".to_string(),
    "info:".to_string(),
    format!("  title: {}", quote(&name)),
    format!("  version: {}", quote(&version)),
    "servers:".to_string(),
    format!("  - url: {}", quote(&server)),
    "paths:".to_string(),
  ];

  for route in routes {
    let route = as_map(route, "route")?;
    let method = get_string(route, "method", "").to_lowercase();
    let path = get_string(route, "path", "");
    if method.is_empty() || path.is_empty() {
      return Err("each route needs method and path".to_string());
    }

    out.push(format!("  {path}:"));
    out.push(format!("    {method}:"));
    out.push(format!("      summary: {}", quote(&get_string(route, "summary", ""))));

    let permission = get_string(route, "permission", "");
    if !permission.is_empty() {
      out.push("      x-permission:".to_string());
      out.push(format!("        name: {}", quote(&permission)));
    }

    let route_headers = match route.get("headers") {
      Some(value) => as_list(value, "headers")?,
      None => headers,
    };
    if !route_headers.is_empty() {
      out.push("      parameters:".to_string());
      for header in route_headers {
        let Value::String(header) = header else {
          return Err("headers must contain strings".to_string());
        };
        out.push(format!("        - name: {}", quote(header)));
        out.push("          in: header".to_string());
        out.push("          required: true".to_string());
        out.push("          schema:".to_string());
        out.push("            type: string".to_string());
      }
    }

    let body = get_string(route, "requestBody", "");
    if !body.is_empty() {
      out.push("      requestBody:".to_string());
      out.push("        required: true".to_string());
      out.push("        content:".to_string());
      out.push("          application/json:".to_string());
      out.push("            schema:".to_string());
      out.push(format!("              type: {}", quote(&body)));
    }

    out.push("      responses:".to_string());
    let responses = get_list(route, "responses")?;
    if responses.is_empty() {
      out.push("        \"200\":".to_string());
      out.push("          description: \"OK\"".to_string());
    }
    for response in responses {
      let response = as_map(response, "response")?;
      out.push(format!("        {}:", quote(&get_string(response, "status", "200"))));
      out.push(format!(
        "          description: {}",
        quote(&get_string(response, "description", "OK"))
      ));
    }
  }

  Ok(format!("{}\n", out.join("\n")))
}

fn run() -> Result<(), String> {
  let args: Vec<String> = env::args().collect();
  if args.len() != 3 {
    return Err("usage: openapi_from_routes <source routes.yaml> <target openapi.yaml>".to_string());
  }

  let source = Path::new(&args[1]);
  let target = Path::new(&args[2]);
  let data = load_routes(source)?;
  let output = emit_openapi(&data)?;
  if let Some(parent) = target.parent() {
    fs::create_dir_all(parent).map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
  }
  fs::write(target, output).map_err(|error| format!("failed to write {}: {error}", target.display()))?;
  Ok(())
}

fn main() {
  if let Err(error) = run() {
    eprintln!("{error}");
    std::process::exit(1);
  }
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn emits_openapi_from_minimal_routes() {
    let data = Value::Map(BTreeMap::from([
      (
        "service".to_string(),
        Value::Map(BTreeMap::from([
          ("name".to_string(), Value::String("api-sales".to_string())),
          ("version".to_string(), Value::String("0.1.0".to_string())),
          (
            "server".to_string(),
            Value::String("https://api-sales.eqeqo.pe".to_string()),
          ),
        ])),
      ),
      (
        "auth".to_string(),
        Value::Map(BTreeMap::from([(
          "headers".to_string(),
          Value::List(vec![
            Value::String("user-token".to_string()),
            Value::String("business-id".to_string()),
          ]),
        )])),
      ),
      (
        "routes".to_string(),
        Value::List(vec![Value::Map(BTreeMap::from([
          ("method".to_string(), Value::String("GET".to_string())),
          ("path".to_string(), Value::String("/products".to_string())),
          ("summary".to_string(), Value::String("List products".to_string())),
          ("permission".to_string(), Value::String("products.read".to_string())),
          (
            "responses".to_string(),
            Value::List(vec![Value::Map(BTreeMap::from([
              ("status".to_string(), Value::String("200".to_string())),
              ("description".to_string(), Value::String("Product list".to_string())),
            ]))]),
          ),
        ]))]),
      ),
    ]));

    let output = emit_openapi(&data).expect("openapi output");

    assert!(output.contains("openapi: 3.0.3"));
    assert!(output.contains("title: \"api-sales\""));
    assert!(output.contains("  /products:"));
    assert!(output.contains("    get:"));
    assert!(output.contains("name: \"products.read\""));
    assert!(output.contains("name: \"user-token\""));
    assert!(output.contains("\"200\":"));
  }
}