hen 0.18.1

Run protocol-aware API request collections from the command line or through MCP.
Documentation
use std::collections::{HashMap, HashSet};

use crate::request::{OAuthFieldMapping, OAuthProfile};

use super::Rule;

const ALLOWED_OAUTH_FIELDS: &[&str] = &[
    "grant",
    "issuer",
    "token_url",
    "client_id",
    "client_secret",
    "scope",
    "refresh_token",
];

pub(super) fn parse_oauth_block(
    pair: pest::iterators::Pair<Rule>,
) -> Result<OAuthProfile, pest::error::Error<Rule>> {
    let span = pair.as_span();
    let mut inner = pair.into_inner();
    let name = inner
        .next()
        .expect("oauth block should include a name")
        .as_str()
        .trim()
        .to_string();
    let mut fields = HashMap::new();
    let mut params = HashMap::new();
    let mut field_mappings = Vec::new();
    let mut mapped_source_fields = HashSet::new();
    let mut mapped_target_variables = HashSet::new();

    for item in inner {
        match item.as_rule() {
            Rule::oauth_assignment => {
                let item_span = item.as_span();
                let mut parts = item.into_inner();
                let key = parts
                    .next()
                    .expect("oauth assignment should include a key")
                    .as_str()
                    .trim()
                    .to_string();
                let value = parts
                    .next()
                    .expect("oauth assignment should include a value")
                    .as_str()
                    .trim()
                    .to_string();

                if !ALLOWED_OAUTH_FIELDS.iter().any(|candidate| candidate == &key) {
                    return Err(pest::error::Error::new_from_span(
                        pest::error::ErrorVariant::CustomError {
                            message: format!(
                                "OAuth profile '{}' uses unsupported field '{}'. Supported fields are: {}.",
                                name,
                                key,
                                ALLOWED_OAUTH_FIELDS.join(", ")
                            ),
                        },
                        item_span,
                    ));
                }

                if fields.insert(key.clone(), value).is_some() {
                    return Err(pest::error::Error::new_from_span(
                        pest::error::ErrorVariant::CustomError {
                            message: format!(
                                "OAuth profile '{}' defines field '{}' more than once.",
                                name, key
                            ),
                        },
                        item_span,
                    ));
                }
            }
            Rule::oauth_param => {
                let item_span = item.as_span();
                let mut parts = item.into_inner();
                let key = parts
                    .next()
                    .expect("oauth param should include a key")
                    .as_str()
                    .trim()
                    .to_string();
                let value = parts
                    .next()
                    .expect("oauth param should include a value")
                    .as_str()
                    .trim()
                    .to_string();

                if params.insert(key.clone(), value).is_some() {
                    return Err(pest::error::Error::new_from_span(
                        pest::error::ErrorVariant::CustomError {
                            message: format!(
                                "OAuth profile '{}' defines param '{}' more than once.",
                                name, key
                            ),
                        },
                        item_span,
                    ));
                }
            }
            Rule::oauth_field_mapping => {
                let item_span = item.as_span();
                let mut parts = item.into_inner();
                let source_field = parts
                    .next()
                    .expect("oauth field mapping should include a source field")
                    .as_str()
                    .trim()
                    .to_string();
                let target_variable = parts
                    .next()
                    .expect("oauth field mapping should include a target variable")
                    .into_inner()
                    .next()
                    .expect("oauth target variable should include an identifier")
                    .as_str()
                    .trim()
                    .to_string();

                if !mapped_source_fields.insert(source_field.clone()) {
                    return Err(pest::error::Error::new_from_span(
                        pest::error::ErrorVariant::CustomError {
                            message: format!(
                                "OAuth profile '{}' maps field '{}' more than once.",
                                name, source_field
                            ),
                        },
                        item_span,
                    ));
                }

                if !mapped_target_variables.insert(target_variable.clone()) {
                    return Err(pest::error::Error::new_from_span(
                        pest::error::ErrorVariant::CustomError {
                            message: format!(
                                "OAuth profile '{}' maps more than one field into '${}'.",
                                name, target_variable
                            ),
                        },
                        item_span,
                    ));
                }

                field_mappings.push(OAuthFieldMapping {
                    source_field,
                    target_variable,
                });
            }
            _ => unreachable!("unexpected oauth item: {:?}", item.as_rule()),
        }
    }

    validate_oauth_profile(&name, &fields, span.clone())?;

    Ok(OAuthProfile {
        name,
        grant: fields
            .get("grant")
            .expect("validated oauth profile should include a grant")
            .clone(),
        fields,
        params,
        field_mappings,
    })
}

fn validate_oauth_profile(
    profile_name: &str,
    fields: &HashMap<String, String>,
    span: pest::Span<'_>,
) -> Result<(), pest::error::Error<Rule>> {
    let grant = fields.get("grant").ok_or_else(|| {
        pest::error::Error::new_from_span(
            pest::error::ErrorVariant::CustomError {
                message: format!("OAuth profile '{}' requires 'grant = ...'.", profile_name),
            },
            span.clone(),
        )
    })?;

    let has_issuer = fields.contains_key("issuer");
    let has_token_url = fields.contains_key("token_url");
    if has_issuer == has_token_url {
        return Err(pest::error::Error::new_from_span(
            pest::error::ErrorVariant::CustomError {
                message: format!(
                    "OAuth profile '{}' must define exactly one of 'issuer' or 'token_url'.",
                    profile_name
                ),
            },
            span.clone(),
        ));
    }

    match grant.as_str() {
        "client_credentials" => {
            for field in ["client_id", "client_secret"] {
                if !fields.contains_key(field) {
                    return Err(pest::error::Error::new_from_span(
                        pest::error::ErrorVariant::CustomError {
                            message: format!(
                                "OAuth profile '{}' with grant 'client_credentials' requires '{} = ...'.",
                                profile_name, field
                            ),
                        },
                        span.clone(),
                    ));
                }
            }
        }
        "refresh_token" => {
            if !fields.contains_key("refresh_token") {
                return Err(pest::error::Error::new_from_span(
                    pest::error::ErrorVariant::CustomError {
                        message: format!(
                            "OAuth profile '{}' with grant 'refresh_token' requires 'refresh_token = ...'.",
                            profile_name
                        ),
                    },
                    span.clone(),
                ));
            }
        }
        other => {
            return Err(pest::error::Error::new_from_span(
                pest::error::ErrorVariant::CustomError {
                    message: format!(
                        "OAuth profile '{}' uses unsupported grant '{}'. Supported grants are: client_credentials, refresh_token.",
                        profile_name, other
                    ),
                },
                span,
            ));
        }
    }

    Ok(())
}