apache-avro 0.22.0

A library for working with Apache Avro in Rust
Documentation
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

//! # Custom name validation
//!
//! By default, the library follows the rules specified in the [Avro specification](https://avro.apache.org/docs/++version++/specification/#names).
//!
//! Some of the other Apache Avro language SDKs are more flexible in their name validation. For
//! interoperability with those SDKs, the library provides a way to customize the name validation.
//!
//! ```
//! # use apache_avro::{AvroResult, validator::{SchemaNameValidator, set_schema_name_validator}};
//! # use regex_lite::Regex;
//! # use std::sync::OnceLock;
//! struct DontAllowNamespaces;
//!
//! impl SchemaNameValidator for DontAllowNamespaces {
//!     fn regex(&self) -> &'static Regex {
//!         static SCHEMA_NAME_ONCE: OnceLock<Regex> = OnceLock::new();
//!         SCHEMA_NAME_ONCE.get_or_init(|| {
//!             Regex::new(
//!                 // Disallows any namespace. By naming the group `name`, the default
//!                 // implementation of `SchemaNameValidator::validate` can be reused.
//!                 r"^(?P<name>[A-Za-z_][A-Za-z0-9_]*)$",
//!             ).expect("Regex is valid")
//!         })
//!     }
//! }
//!
//! // don't parse any schema before registering the custom validator(s)!
//!
//! if set_schema_name_validator(Box::new(DontAllowNamespaces)).is_err() {
//!     // `.unwrap()` doesn't work as the return type does not implement `Debug`
//!     panic!("There was already a schema validator configured")
//! }
//!
//! // ... use the library
//! ```
//!
//! Similar logic could be applied to the schema namespace, enum symbols and field names validation.
//!
//! **Note**: the library allows to set a validator only once per the application lifetime!
//! If the application parses schemas before setting a validator, the default validator will be
//! registered and used!

use crate::{AvroResult, error::Details};
use log::debug;
use regex_lite::Regex;
use std::sync::OnceLock;

/// A validator that validates names and namespaces according to the Avro specification.
struct SpecificationValidator;

/// A trait that validates schema names.
///
/// To register a custom one use [`set_schema_name_validator`].
pub trait SchemaNameValidator: Send + Sync {
    /// The regex used to validate the schema name.
    ///
    /// When the name part of the full name is provided as a capture group named `name`, the
    /// default implementation of [`Self::validate`] can be used.
    ///
    /// The default implementation uses the Avro specified regex.
    fn regex(&self) -> &'static Regex {
        static SCHEMA_NAME_ONCE: OnceLock<Regex> = OnceLock::new();
        SCHEMA_NAME_ONCE.get_or_init(|| {
            Regex::new(
                // An optional namespace (with optional dots) followed by a name without any dots in it.
                r"^((?P<namespace>([A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*)?)\.)?(?P<name>[A-Za-z_][A-Za-z0-9_]*)$",
            )
                .unwrap()
        })
    }

    /// Validates the schema name and returns the start byte of the name.
    ///
    /// Requires that the implementation of [`Self::regex`] provides a capture group named `name`
    /// that captures the name part of the full name.
    ///
    /// Should return [`Details::InvalidSchemaName`] if it is invalid.
    fn validate(&self, schema_name: &str) -> AvroResult<usize> {
        let regex = SchemaNameValidator::regex(self);
        let caps = regex
            .captures(schema_name)
            .ok_or_else(|| Details::InvalidSchemaName(schema_name.to_string(), regex.as_str()))?;
        Ok(caps
            .name("name")
            .ok_or(Details::InvalidSchemaNameValidatorImplementation)?
            .start())
    }
}

impl SchemaNameValidator for SpecificationValidator {}

static NAME_VALIDATOR_ONCE: OnceLock<Box<dyn SchemaNameValidator + Send + Sync>> = OnceLock::new();

/// Sets a custom schema name validator.
///
/// Returns `Err(validator)` if a validator is already configured.
///
/// **Note**: This function must be called before parsing any schema because this will
/// register the default validator and the registration is one time only!
pub fn set_schema_name_validator(
    validator: Box<dyn SchemaNameValidator + Send + Sync>,
) -> Result<(), Box<dyn SchemaNameValidator + Send + Sync>> {
    debug!("Setting a custom schema name validator.");
    NAME_VALIDATOR_ONCE.set(validator)
}

pub(crate) fn validate_schema_name(schema_name: &str) -> AvroResult<usize> {
    NAME_VALIDATOR_ONCE
        .get_or_init(|| {
            debug!("Going to use the default name validator.");
            Box::new(SpecificationValidator)
        })
        .validate(schema_name)
}

/// A trait that validates schema namespaces.
///
/// To register a custom one use [`set_schema_namespace_validator`].
pub trait SchemaNamespaceValidator: Send + Sync {
    /// The regex used to validate the schema namespace.
    ///
    /// The default implementation uses the Avro specified regex.
    fn regex(&self) -> &'static Regex {
        static NAMESPACE_ONCE: OnceLock<Regex> = OnceLock::new();
        NAMESPACE_ONCE.get_or_init(|| {
            Regex::new(r"^([A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*)?$").unwrap()
        })
    }

    /// Validates a schema namespace.
    ///
    /// Should return [`Details::InvalidNamespace`] if it is invalid.
    fn validate(&self, namespace: &str) -> AvroResult<()> {
        let regex = SchemaNamespaceValidator::regex(self);
        if !regex.is_match(namespace) {
            Err(Details::InvalidNamespace(namespace.to_string(), regex.as_str()).into())
        } else {
            Ok(())
        }
    }
}

impl SchemaNamespaceValidator for SpecificationValidator {}

static NAMESPACE_VALIDATOR_ONCE: OnceLock<Box<dyn SchemaNamespaceValidator + Send + Sync>> =
    OnceLock::new();

/// Sets a custom schema namespace validator.
///
/// Returns `Err(validator)` if a validator is already configured.
///
/// **Note**: This function must be called before parsing any schema because this will
/// register the default validator and the registration is one time only!
pub fn set_schema_namespace_validator(
    validator: Box<dyn SchemaNamespaceValidator + Send + Sync>,
) -> Result<(), Box<dyn SchemaNamespaceValidator + Send + Sync>> {
    NAMESPACE_VALIDATOR_ONCE.set(validator)
}

pub(crate) fn validate_namespace(ns: &str) -> AvroResult<()> {
    NAMESPACE_VALIDATOR_ONCE
        .get_or_init(|| {
            debug!("Going to use the default namespace validator.");
            Box::new(SpecificationValidator)
        })
        .validate(ns)
}

/// A trait that validates enum symbol names.
///
/// To register a custom one use [`set_enum_symbol_name_validator`].
pub trait EnumSymbolNameValidator: Send + Sync {
    /// The regex used to validate the symbols of enums.
    ///
    /// The default implementation uses the Avro specified regex.
    fn regex(&self) -> &'static Regex {
        static ENUM_SYMBOL_NAME_ONCE: OnceLock<Regex> = OnceLock::new();
        ENUM_SYMBOL_NAME_ONCE.get_or_init(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").unwrap())
    }

    /// Validate the symbol of an enum.
    ///
    /// Should return [`Details::EnumSymbolName`] if it is invalid.
    fn validate(&self, symbol: &str) -> AvroResult<()> {
        let regex = EnumSymbolNameValidator::regex(self);
        if !regex.is_match(symbol) {
            return Err(Details::EnumSymbolName(symbol.to_string()).into());
        }

        Ok(())
    }
}

impl EnumSymbolNameValidator for SpecificationValidator {}

static ENUM_SYMBOL_NAME_VALIDATOR_ONCE: OnceLock<Box<dyn EnumSymbolNameValidator + Send + Sync>> =
    OnceLock::new();

/// Sets a custom enum symbol name validator.
///
/// Returns `Err(validator)` if a validator is already configured.
///
/// **Note**: This function must be called before parsing any schema because this will
/// register the default validator and the registration is one time only!
pub fn set_enum_symbol_name_validator(
    validator: Box<dyn EnumSymbolNameValidator + Send + Sync>,
) -> Result<(), Box<dyn EnumSymbolNameValidator + Send + Sync>> {
    ENUM_SYMBOL_NAME_VALIDATOR_ONCE.set(validator)
}

pub(crate) fn validate_enum_symbol_name(symbol: &str) -> AvroResult<()> {
    ENUM_SYMBOL_NAME_VALIDATOR_ONCE
        .get_or_init(|| {
            debug!("Going to use the default enum symbol name validator.");
            Box::new(SpecificationValidator)
        })
        .validate(symbol)
}

/// A trait that validates record field names.
///
/// To register a custom one use [`set_record_field_name_validator`].
pub trait RecordFieldNameValidator: Send + Sync {
    /// The regex used to validate the record field names.
    ///
    /// The default implementation uses the Avro specified regex.
    fn regex(&self) -> &'static Regex {
        static FIELD_NAME_ONCE: OnceLock<Regex> = OnceLock::new();
        FIELD_NAME_ONCE.get_or_init(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").unwrap())
    }

    /// Validate the name of a record field.
    ///
    /// Should return [`Details::FieldName`] if it is invalid.
    fn validate(&self, field_name: &str) -> AvroResult<()> {
        let regex = RecordFieldNameValidator::regex(self);
        if !regex.is_match(field_name) {
            return Err(Details::FieldName(field_name.to_string()).into());
        }

        Ok(())
    }
}

impl RecordFieldNameValidator for SpecificationValidator {}

static RECORD_FIELD_NAME_VALIDATOR_ONCE: OnceLock<Box<dyn RecordFieldNameValidator + Send + Sync>> =
    OnceLock::new();

/// Sets a custom record field name validator.
///
/// Returns `Err(validator)` if a validator is already configured.
///
/// **Note**: This function must be called before parsing any schema because this will
/// register the default validator and the registration is one time only!
pub fn set_record_field_name_validator(
    validator: Box<dyn RecordFieldNameValidator + Send + Sync>,
) -> Result<(), Box<dyn RecordFieldNameValidator + Send + Sync>> {
    RECORD_FIELD_NAME_VALIDATOR_ONCE.set(validator)
}

pub(crate) fn validate_record_field_name(field_name: &str) -> AvroResult<()> {
    RECORD_FIELD_NAME_VALIDATOR_ONCE
        .get_or_init(|| {
            debug!("Going to use the default record field name validator.");
            Box::new(SpecificationValidator)
        })
        .validate(field_name)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::schema::Name;
    use apache_avro_test_helper::TestResult;

    #[test]
    fn avro_3900_default_name_validator_with_valid_ns() -> TestResult {
        validate_schema_name("example")?;
        Ok(())
    }

    #[test]
    fn avro_3900_default_name_validator_with_invalid_ns() -> TestResult {
        assert!(validate_schema_name("com-example").is_err());
        Ok(())
    }

    #[test]
    fn test_avro_3897_disallow_invalid_namespaces_in_fully_qualified_name() -> TestResult {
        let full_name = "ns.0.record1";
        let name = Name::new(full_name);
        assert!(name.is_err());
        let validator = SpecificationValidator;
        let expected = Details::InvalidSchemaName(
            full_name.to_string(),
            SchemaNameValidator::regex(&validator).as_str(),
        )
        .to_string();
        let err = name.map_err(|e| e.to_string()).err().unwrap();
        pretty_assertions::assert_eq!(expected, err);

        let full_name = "ns..record1";
        let name = Name::new(full_name);
        assert!(name.is_err());
        let expected = Details::InvalidSchemaName(
            full_name.to_string(),
            SchemaNameValidator::regex(&validator).as_str(),
        )
        .to_string();
        let err = name.map_err(|e| e.to_string()).err().unwrap();
        pretty_assertions::assert_eq!(expected, err);
        Ok(())
    }

    #[test]
    fn avro_3900_default_namespace_validator_with_valid_ns() -> TestResult {
        validate_namespace("com.example")?;
        Ok(())
    }

    #[test]
    fn avro_3900_default_namespace_validator_with_invalid_ns() -> TestResult {
        assert!(validate_namespace("com-example").is_err());
        Ok(())
    }

    #[test]
    fn avro_3900_default_enum_symbol_validator_with_valid_symbol_name() -> TestResult {
        validate_enum_symbol_name("spades")?;
        Ok(())
    }

    #[test]
    fn avro_3900_default_enum_symbol_validator_with_invalid_symbol_name() -> TestResult {
        assert!(validate_enum_symbol_name("com-example").is_err());
        Ok(())
    }

    #[test]
    fn avro_3900_default_record_field_validator_with_valid_name() -> TestResult {
        validate_record_field_name("test")?;
        Ok(())
    }

    #[test]
    fn avro_3900_default_record_field_validator_with_invalid_name() -> TestResult {
        assert!(validate_record_field_name("com-example").is_err());
        Ok(())
    }
}