#![allow(clippy::doc_markdown)]
use std::collections::HashMap;
use std::path::Path;
use openapiv3::{OpenAPI, ReferenceOr};
use serde::{Deserialize, Serialize};
use super::HttpConnectorError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Operation {
pub method: String,
pub path: String,
#[serde(default)]
pub parameters: Vec<Parameter>,
#[serde(default)]
pub has_request_body: bool,
#[serde(default)]
pub base_url: Option<String>,
}
impl Operation {
#[must_use]
pub fn path_parameters(&self) -> Vec<&Parameter> {
self.parameters
.iter()
.filter(|p| p.location == ParameterLocation::Path)
.collect()
}
#[must_use]
pub fn query_parameters(&self) -> Vec<&Parameter> {
self.parameters
.iter()
.filter(|p| p.location == ParameterLocation::Query)
.collect()
}
#[must_use]
pub fn header_parameters(&self) -> Vec<&Parameter> {
self.parameters
.iter()
.filter(|p| p.location == ParameterLocation::Header)
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Parameter {
pub name: String,
pub location: ParameterLocation,
#[serde(default)]
pub required: bool,
}
impl Parameter {
#[must_use]
pub fn new(name: impl Into<String>, location: ParameterLocation, required: bool) -> Self {
Self {
name: name.into(),
location,
required,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ParameterLocation {
Path,
Query,
Header,
}
#[derive(Debug, Clone)]
pub struct OpenApiSchema {
spec_text: String,
operations: Vec<Operation>,
by_path: HashMap<(String, String), usize>,
}
impl OpenApiSchema {
pub fn parse(text: &str) -> Result<Self, HttpConnectorError> {
let spec: OpenAPI = serde_json::from_str(text)
.or_else(|_| serde_yaml::from_str(text))
.map_err(|_| {
HttpConnectorError::Backend("OpenAPI spec is not valid JSON or YAML".to_string())
})?;
Self::from_spec(spec, text.to_string())
}
pub fn parse_path(path: &Path) -> Result<Self, HttpConnectorError> {
let text = std::fs::read_to_string(path).map_err(|_| {
HttpConnectorError::Backend("could not read OpenAPI spec file".to_string())
})?;
Self::parse(&text)
}
fn from_spec(spec: OpenAPI, spec_text: String) -> Result<Self, HttpConnectorError> {
let mut operations = Vec::new();
let mut by_path = HashMap::new();
for (path, path_item) in &spec.paths.paths {
let item = match path_item {
ReferenceOr::Item(item) => item,
ReferenceOr::Reference { .. } => continue,
};
let path_level: Vec<Parameter> = item
.parameters
.iter()
.filter_map(convert_parameter)
.collect();
let methods = [
("GET", &item.get),
("POST", &item.post),
("PUT", &item.put),
("PATCH", &item.patch),
("DELETE", &item.delete),
("HEAD", &item.head),
("OPTIONS", &item.options),
];
for (method, op_opt) in methods {
if let Some(op) = op_opt {
let operation = extract_operation(path, method, op, &path_level);
let idx = operations.len();
by_path.insert((path.clone(), method.to_string()), idx);
operations.push(operation);
}
}
}
Ok(Self {
spec_text,
operations,
by_path,
})
}
#[must_use]
pub fn operations(&self) -> &[Operation] {
&self.operations
}
#[must_use]
pub fn operation_for(&self, path: &str, method: &str) -> Option<&Operation> {
self.by_path
.get(&(path.to_string(), method.to_uppercase()))
.and_then(|&idx| self.operations.get(idx))
}
#[must_use]
pub fn spec_text(&self) -> &str {
&self.spec_text
}
}
fn extract_operation(
path: &str,
method: &str,
op: &openapiv3::Operation,
path_level: &[Parameter],
) -> Operation {
let mut parameters: Vec<Parameter> = path_level.to_vec();
for param_ref in &op.parameters {
if let Some(p) = convert_parameter(param_ref) {
if let Some(idx) = parameters.iter().position(|x| x.name == p.name) {
parameters[idx] = p;
} else {
parameters.push(p);
}
}
}
Operation {
method: method.to_string(),
path: path.to_string(),
parameters,
has_request_body: op.request_body.is_some(),
base_url: None,
}
}
fn convert_parameter(param_ref: &ReferenceOr<openapiv3::Parameter>) -> Option<Parameter> {
let param = match param_ref {
ReferenceOr::Item(p) => p,
ReferenceOr::Reference { .. } => return None,
};
match param {
openapiv3::Parameter::Query { parameter_data, .. } => Some(Parameter::new(
parameter_data.name.clone(),
ParameterLocation::Query,
parameter_data.required,
)),
openapiv3::Parameter::Path { parameter_data, .. } => Some(Parameter::new(
parameter_data.name.clone(),
ParameterLocation::Path,
true,
)),
openapiv3::Parameter::Header { parameter_data, .. } => Some(Parameter::new(
parameter_data.name.clone(),
ParameterLocation::Header,
parameter_data.required,
)),
openapiv3::Parameter::Cookie { .. } => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_JSON: &str = r#"
{
"openapi": "3.0.0",
"info": { "title": "Test API", "version": "1.0.0" },
"paths": {
"/users/{id}": {
"get": {
"operationId": "getUser",
"parameters": [
{ "name": "id", "in": "path", "required": true,
"schema": { "type": "string" } },
{ "name": "verbose", "in": "query", "required": false,
"schema": { "type": "boolean" } }
],
"responses": { "200": { "description": "OK" } }
}
}
}
}
"#;
const SAMPLE_YAML: &str = r#"
openapi: 3.0.0
info:
title: Test API
version: 1.0.0
paths:
/users/{id}:
get:
operationId: getUser
parameters:
- name: id
in: path
required: true
schema:
type: string
- name: verbose
in: query
required: false
schema:
type: boolean
responses:
'200':
description: OK
"#;
fn assert_get_user(schema: &OpenApiSchema) {
let op = schema
.operation_for("/users/{id}", "GET")
.expect("getUser operation present");
assert_eq!(op.method, "GET");
assert_eq!(op.path, "/users/{id}");
let path_params: Vec<&str> = op
.path_parameters()
.iter()
.map(|p| p.name.as_str())
.collect();
assert_eq!(path_params, vec!["id"]);
let query_params: Vec<&str> = op
.query_parameters()
.iter()
.map(|p| p.name.as_str())
.collect();
assert_eq!(query_params, vec!["verbose"]);
}
#[test]
fn schema_parse_json_extracts_operation_and_path_params() {
let schema = OpenApiSchema::parse(SAMPLE_JSON).expect("parse JSON");
assert_get_user(&schema);
assert_eq!(schema.operations().len(), 1);
}
#[test]
fn schema_parse_yaml_matches_json() {
let schema = OpenApiSchema::parse(SAMPLE_YAML).expect("parse YAML");
assert_get_user(&schema);
}
#[test]
fn schema_parse_retains_spec_text_for_resource() {
let schema = OpenApiSchema::parse(SAMPLE_JSON).expect("parse JSON");
assert_eq!(schema.spec_text(), SAMPLE_JSON);
}
#[test]
fn schema_parse_method_case_insensitive_lookup() {
let schema = OpenApiSchema::parse(SAMPLE_JSON).expect("parse JSON");
assert!(schema.operation_for("/users/{id}", "get").is_some());
assert!(schema.operation_for("/users/{id}", "GET").is_some());
assert!(schema.operation_for("/users/{id}", "POST").is_none());
}
#[test]
fn schema_parse_malformed_returns_typed_error_no_panic() {
let err = OpenApiSchema::parse("this is neither json nor yaml: [unclosed").unwrap_err();
assert!(matches!(err, HttpConnectorError::Backend(_)));
}
#[test]
fn test_schema_parse_error_display_no_secret() {
let secret_marker = "SUPER_SECRET_TOKEN_abc123";
let bad_spec = format!("not-a-spec {secret_marker} [");
let err = OpenApiSchema::parse(&bad_spec).unwrap_err();
let rendered = format!("{err}");
assert!(
!rendered.contains(secret_marker),
"parser error must not echo the spec body; got {rendered:?}"
);
}
}