apollo-errors 0.7.0

Structured error handling with automatic format conversion
Documentation
//! Metadata structures for error catalog

use serde::Serialize;

/// Field name case for extension field names in error output.
///
/// Applied to every `#[extension]` field name at render time. When a field uses
/// `#[extension(rename = "...")]`, the rename value is the input; otherwise the
/// Rust field name is used.
///
/// See [`FormatConfig`] for how to set this per renderer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldCase {
    /// `"config_file"` → `"config_file"` (default, no transformation)
    SnakeCase,
    /// `"config_file"` → `"configFile"`
    CamelCase,
    /// `"config_file"` → `"ConfigFile"`
    PascalCase,
    /// `"config_file"` → `"CONFIG_FILE"`
    ScreamingSnakeCase,
    /// `"config_file"` → `"config-file"`
    KebabCase,
}

/// Error code case for error codes in error output.
///
/// Applied to every error code at render time. `::` separators are treated as
/// word boundaries for all non-[`Default`](CodeCase::Default) variants.
///
/// See [`FormatConfig`] for how to set this per renderer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodeCase {
    /// `"config::invalid_port"` → `"config::invalid_port"` (no transformation)
    Default,
    /// `"config::invalid_port"` → `"CONFIG_INVALID_PORT"`
    ScreamingSnakeCase,
    /// `"config::invalid_port"` → `"configInvalidPort"`
    CamelCase,
    /// `"config::invalid_port"` → `"ConfigInvalidPort"`
    PascalCase,
    /// `"config::invalid_port"` → `"config-invalid-port"`
    KebabCase,
}

/// Configuration for error output formatting
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FormatConfig {
    pub field_case: FieldCase,
    pub code_case: CodeCase,
}

impl Default for FormatConfig {
    fn default() -> Self {
        Self {
            field_case: FieldCase::SnakeCase,
            code_case: CodeCase::Default,
        }
    }
}

/// Metadata for a field within an error variant
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FieldMetadata {
    /// The field name in Rust (snake_case)
    pub rust_name: &'static str,

    /// The output name for serialization — explicit rename if set, else equals `rust_name`
    pub output_name: &'static str,

    /// Pre-computed snake_case variant, e.g. `"config_file"`
    pub snake_case: &'static str,

    /// Pre-computed camelCase variant, e.g. `"configFile"`
    pub camel_case: &'static str,

    /// Pre-computed PascalCase variant, e.g. `"ConfigFile"`
    pub pascal_case: &'static str,

    /// Pre-computed SCREAMING_SNAKE_CASE variant, e.g. `"CONFIG_FILE"`
    pub screaming_snake_case: &'static str,

    /// Pre-computed kebab-case variant, e.g. `"config-file"`
    pub kebab_case: &'static str,

    /// The Rust type as a string
    pub ty: &'static str,

    /// Whether this field is an extension field
    pub is_extension: bool,

    /// HTTP header name if this field should be returned as a header
    #[serde(skip_serializing_if = "Option::is_none")]
    pub http_header: Option<&'static str>,
}

impl FieldMetadata {
    /// Return the field name in the requested case.
    pub fn name_for(&self, case: FieldCase) -> &'static str {
        match case {
            FieldCase::SnakeCase => self.snake_case,
            FieldCase::CamelCase => self.camel_case,
            FieldCase::PascalCase => self.pascal_case,
            FieldCase::ScreamingSnakeCase => self.screaming_snake_case,
            FieldCase::KebabCase => self.kebab_case,
        }
    }
}

/// All pre-computed case variants for an error code.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CodeMetadata {
    /// Original form, e.g. `"config::invalid_port"`
    pub default: &'static str,
    /// `"CONFIG_INVALID_PORT"`
    pub screaming_snake: &'static str,
    /// `"configInvalidPort"`
    pub camel: &'static str,
    /// `"ConfigInvalidPort"`
    pub pascal: &'static str,
    /// `"config-invalid-port"`
    pub kebab: &'static str,
}

/// Metadata for a regular error variant with its own message, code, and status
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RegularVariantMetadata {
    /// The variant name
    pub name: &'static str,

    /// Error message template
    pub message: &'static str,

    /// Error code in its original form, e.g. `"config::invalid_port"`
    pub code: &'static str,

    /// Pre-computed `CONFIG_INVALID_PORT` form
    pub code_screaming_snake: &'static str,

    /// Pre-computed `configInvalidPort` form
    pub code_camel: &'static str,

    /// Pre-computed `ConfigInvalidPort` form
    pub code_pascal: &'static str,

    /// Pre-computed `config-invalid-port` form
    pub code_kebab: &'static str,

    /// HTTP status code
    pub http_status: u16,

    /// JSON-RPC error code
    pub jsonrpc_code: i32,

    /// Optional help text
    #[serde(skip_serializing_if = "Option::is_none")]
    pub help: Option<&'static str>,

    /// Optional documentation URL
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<&'static str>,

    /// Optional severity
    #[serde(skip_serializing_if = "Option::is_none")]
    pub severity: Option<&'static str>,

    /// Fields in this variant
    pub fields: &'static [FieldMetadata],
}

impl RegularVariantMetadata {
    /// Return the error code in the requested case.
    pub fn code_for(&self, case: CodeCase) -> &'static str {
        match case {
            CodeCase::Default => self.code,
            CodeCase::ScreamingSnakeCase => self.code_screaming_snake,
            CodeCase::CamelCase => self.code_camel,
            CodeCase::PascalCase => self.code_pascal,
            CodeCase::KebabCase => self.code_kebab,
        }
    }
}

/// Metadata for a transparent variant that forwards to another error type
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TransparentVariantMetadata {
    /// The variant name
    pub name: &'static str,

    /// The type name this variant forwards to
    pub forward_to: &'static str,
}

/// Metadata for a single error variant
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum VariantMetadata {
    /// A regular error variant
    Regular(RegularVariantMetadata),

    /// A transparent variant that forwards to another error type
    Transparent(TransparentVariantMetadata),
}

/// Metadata for an error enum type
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ErrorMetadata {
    /// The Rust type name
    pub type_name: &'static str,

    /// All variants of this error type
    pub variants: &'static [VariantMetadata],
}

#[cfg(test)]
mod tests {
    use super::{
        CodeCase, ErrorMetadata, FieldCase, FieldMetadata, RegularVariantMetadata, VariantMetadata,
    };

    #[test]
    fn test_serialize_error_metadata() {
        static FIELDS: &[FieldMetadata] = &[];
        static VARIANTS: &[VariantMetadata] = &[VariantMetadata::Regular(RegularVariantMetadata {
            name: "TestVariant",
            message: "test message",
            code: "test::code",
            code_screaming_snake: "TEST_CODE",
            code_camel: "testCode",
            code_pascal: "TestCode",
            code_kebab: "test-code",
            http_status: 200,
            jsonrpc_code: -32000,
            help: None,
            url: None,
            severity: None,
            fields: FIELDS,
        })];

        let metadata = ErrorMetadata {
            type_name: "TestError",
            variants: VARIANTS,
        };

        let json = serde_json::to_value(&metadata).unwrap();
        assert_eq!(json["typeName"], "TestError");
        let variants_json = json["variants"].as_array().unwrap();
        assert_eq!(variants_json.len(), 1);
        assert_eq!(variants_json[0]["name"], "TestVariant");
        assert_eq!(variants_json[0]["code"], "test::code");
    }

    #[test]
    fn test_field_metadata_name_for() {
        let field_metadata = FieldMetadata {
            rust_name: "my_field",
            output_name: "my_field",
            snake_case: "my_field",
            camel_case: "myField",
            pascal_case: "MyField",
            screaming_snake_case: "MY_FIELD",
            kebab_case: "my-field",
            ty: "String",
            is_extension: false,
            http_header: None,
        };
        assert_eq!(field_metadata.name_for(FieldCase::SnakeCase), "my_field");
        assert_eq!(field_metadata.name_for(FieldCase::CamelCase), "myField");
        assert_eq!(field_metadata.name_for(FieldCase::PascalCase), "MyField");
        assert_eq!(
            field_metadata.name_for(FieldCase::ScreamingSnakeCase),
            "MY_FIELD"
        );
        assert_eq!(field_metadata.name_for(FieldCase::KebabCase), "my-field");
    }

    #[test]
    fn test_field_metadata_name_for_with_rename() {
        let field = FieldMetadata {
            rust_name: "my_field",
            output_name: "myRenamedField",
            snake_case: "my_renamed_field",
            camel_case: "myRenamedField",
            pascal_case: "MyRenamedField",
            screaming_snake_case: "MY_RENAMED_FIELD",
            kebab_case: "my-renamed-field",
            ty: "String",
            is_extension: true,
            http_header: None,
        };
        assert_eq!(field.name_for(FieldCase::SnakeCase), "my_renamed_field");
        assert_eq!(field.name_for(FieldCase::CamelCase), "myRenamedField");
        assert_eq!(field.name_for(FieldCase::PascalCase), "MyRenamedField");
        assert_eq!(
            field.name_for(FieldCase::ScreamingSnakeCase),
            "MY_RENAMED_FIELD"
        );
        assert_eq!(field.name_for(FieldCase::KebabCase), "my-renamed-field");
    }

    #[test]
    fn test_regular_variant_metadata_code_for() {
        let regular_variant_metadata = RegularVariantMetadata {
            name: "MyError",
            message: "My error message",
            code: "my_error",
            code_screaming_snake: "MY_ERROR",
            code_camel: "myError",
            code_pascal: "MyError",
            code_kebab: "my-error",
            http_status: 500,
            jsonrpc_code: 1000,
            help: None,
            url: None,
            severity: None,
            fields: &[],
        };
        assert_eq!(
            regular_variant_metadata.code_for(CodeCase::Default),
            "my_error"
        );
        assert_eq!(
            regular_variant_metadata.code_for(CodeCase::ScreamingSnakeCase),
            "MY_ERROR"
        );
        assert_eq!(
            regular_variant_metadata.code_for(CodeCase::CamelCase),
            "myError"
        );
        assert_eq!(
            regular_variant_metadata.code_for(CodeCase::PascalCase),
            "MyError"
        );
        assert_eq!(
            regular_variant_metadata.code_for(CodeCase::KebabCase),
            "my-error"
        );
    }
}