openapi-tui 0.10.2

This TUI allows you to list and browse APIs described by the openapi specification.
use color_eyre::eyre::Result;
use openapi_31::v31::parameter::In;
use ratatui::{
  prelude::*,
  widgets::{block::*, *},
};

use crate::{action::Action, components::schema_viewer::SchemaViewer, panes::Pane, state::State, tui::Frame};

pub struct RequestType {
  location: String,
  schema: serde_json::Value,
  title: String,
}

#[derive(Default)]
pub struct RequestPane {
  focused: bool,
  focused_border_style: Style,

  schemas: Vec<RequestType>,
  schemas_index: usize,
  schema_viewer: SchemaViewer,
}

impl RequestPane {
  pub fn new(focused: bool, focused_border_style: Style) -> Self {
    Self {
      focused,
      focused_border_style,
      schemas: Vec::default(),
      schemas_index: 0,
      schema_viewer: SchemaViewer::default(),
    }
  }

  fn border_style(&self) -> Style {
    match self.focused {
      true => self.focused_border_style,
      false => Style::default(),
    }
  }

  fn border_type(&self) -> BorderType {
    match self.focused {
      true => BorderType::Thick,
      false => BorderType::Plain,
    }
  }

  fn location_color(&self, status: &str) -> Color {
    if status.starts_with("header") {
      return Color::LightCyan;
    }
    if status.starts_with("path") {
      return Color::LightBlue;
    }
    if status.starts_with("query") {
      return Color::LightMagenta;
    }
    if status.starts_with("body") {
      return Color::LightYellow;
    }
    if status.starts_with("cookie") {
      return Color::LightRed;
    }
    Color::default()
  }

  fn init_schema(&mut self, state: &State) -> Result<()> {
    {
      self.schemas = vec![];

      macro_rules! push_schema {
        ($map:ident, $title:expr, $location:expr) => {{
          if !$map.is_empty() {
            self.schemas.push(RequestType {
              location: $location.to_string(),
              schema: serde_json::Value::Object($map),
              title: $title.to_string(),
            })
          }
        }};
      }
      if let Some(operation_item) = state.active_operation() {
        if let Some(request_body) = &operation_item.operation.request_body {
          let mut bodies = serde_json::Map::new();

          request_body.resolve(&state.openapi_spec).unwrap().content.iter().for_each(|(media_type, media)| {
            if let Some(schema) = &media.schema {
              bodies.insert(media_type.clone(), schema.clone());
            }
          });

          push_schema!(bodies, "Body", "body");
        }
        let mut query_parameters = serde_json::Map::new();
        let mut header_parameters = serde_json::Map::new();
        let mut path_parameters = serde_json::Map::new();
        let mut cookie_parameters = serde_json::Map::new();
        operation_item.operation.parameters.iter().flatten().for_each(|parameter_or_ref| {
          let parameter = parameter_or_ref.resolve(&state.openapi_spec).unwrap();
          match parameter.r#in {
            In::Query => &mut query_parameters,
            In::Header => &mut header_parameters,
            In::Path => &mut path_parameters,
            In::Cookie => &mut cookie_parameters,
          }
          .insert(parameter.name.clone(), parameter.schema.as_ref().unwrap_or(&serde_json::Value::Null).clone());
        });

        push_schema!(query_parameters, "Query", "query");
        push_schema!(header_parameters, "Header", "header");
        push_schema!(path_parameters, "Path", "path");
        push_schema!(cookie_parameters, "Cookie", "cookie");
      }
    }
    if let Some(request_type) = self.schemas.get(self.schemas_index) {
      self.schema_viewer.set(request_type.schema.clone())?;
    } else {
      self.schema_viewer.clear();
    }
    Ok(())
  }

  fn nested_schema_path_line(&self) -> Line {
    let schema_path = self.schema_viewer.schema_path();
    if schema_path.is_empty() {
      return Line::default();
    }
    let mut line = String::from("[ ");
    line.push_str(&schema_path.join(" > "));
    line.push_str(" ]");
    Line::from(line)
  }
}

impl Pane for RequestPane {
  fn init(&mut self, state: &State) -> Result<()> {
    self.schema_viewer.set_components(state);
    self.init_schema(state)?;
    Ok(())
  }

  fn height_constraint(&self) -> Constraint {
    if self.schemas.get(self.schemas_index).is_none() {
      return Constraint::Min(2);
    }
    match self.focused {
      true => Constraint::Fill(30),
      false => Constraint::Fill(10),
    }
  }

  fn update(&mut self, action: Action, state: &mut State) -> Result<Option<Action>> {
    match action {
      Action::Update => {
        self.schemas_index = 0;
        self.init_schema(state)?;
      },
      Action::Down => {
        self.schema_viewer.down();
      },
      Action::Up => {
        self.schema_viewer.up();
      },
      Action::Tab(index) if index < self.schemas.len().try_into()? => {
        self.schemas_index = index.try_into()?;
        self.init_schema(state)?;
      },
      Action::TabNext => {
        let next_tab_index = self.schemas_index + 1;
        self.schemas_index = if next_tab_index < self.schemas.len() { next_tab_index } else { 0 };
        self.init_schema(state)?;
      },
      Action::TabPrev => {
        self.schemas_index = if self.schemas_index > 0 { self.schemas_index - 1 } else { self.schemas.len() - 1 };
        self.init_schema(state)?;
      },
      Action::Focus => {
        self.focused = true;
        static STATUS_LINE: &str = "[1-9 → select tab] [g,b → go/back definitions]";
        return Ok(Some(Action::TimedStatusLine(STATUS_LINE.into(), 3)));
      },
      Action::UnFocus => {
        self.focused = false;
      },
      Action::Go => self.schema_viewer.go()?,
      Action::Back => {
        if let Some(request_type) = self.schemas.get(self.schemas_index) {
          self.schema_viewer.back(request_type.schema.clone())?;
        }
      },
      _ => {},
    }

    Ok(None)
  }

  fn draw(&mut self, frame: &mut Frame<'_>, area: Rect, _state: &State) -> Result<()> {
    let inner = area.inner(Margin { horizontal: 1, vertical: 1 });

    frame.render_widget(
      Tabs::new(self.schemas.iter().map(|item| {
        let title = item.title.clone();
        Span::styled(title, Style::default().fg(self.location_color(item.location.as_str()))).dim()
      }))
      .highlight_style(Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED).not_dim())
      .select(self.schemas_index),
      inner,
    );

    let mut inner = inner.inner(Margin { horizontal: 1, vertical: 1 });
    inner.height = inner.height.saturating_add(1);
    self.schema_viewer.render_widget(frame, inner);

    frame.render_widget(
      Block::default()
        .title("Request")
        .borders(Borders::ALL)
        .border_style(self.border_style())
        .border_type(self.border_type())
        .title_bottom(
          self
            .nested_schema_path_line()
            .style(Style::default().fg(Color::White).dim().add_modifier(Modifier::ITALIC))
            .left_aligned(),
        ),
      area,
    );

    Ok(())
  }
}