Skip to content

SpineEventEngine/validation

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ubuntu build codecov   license

Spine Validation

The library brings data validation directly into your Protobuf messages.

Why

While Protobuf provides a structured way to define data schemas, it does not include built-in mechanisms for enforcing domain-specific rules at runtime. Without validation, invalid or inconsistent data can slip through, leading to subtle bugs and potential system failures.

Typically, developers address this by manually adding validation logic in application code or by invoking separate validation APIs. This ad-hoc approach can be error-prone and often results in duplicated, hard-to-maintain code scattered across the codebase.

How It Works

Spine Validation solves this by modifying the code generated by the Protobuf compiler (protoc). At build time, Spine Validation injects assertions directly into the generated Java classes, enabling automatic enforcement of constraints without explicit API calls in application code.

Table of Contents

Prerequisites

This library is built with Java 17.

Validation in Action

Define your validation rules right in the .proto file:

import "spine/options.proto";
import "spine/time_options.proto";

import "google/protobuf/timestamp.proto";

message CardNumber {
    string digits = 1 [(pattern).regex = "\\d{4}\\s?\\d{4}\\s?\\d{4}\\s?\\d{4}"];
    string owner = 2 [(required) = true];
    google.protobuf.Timestamp issued_at = 3 [(when).in = PAST];
}

At build time, Spine Validation injects assertions into the generated Java classes:

var card = CardNumber.newBuilder()
    .setDigits("invalid")
    .build(); <- Validates here.

If any constraint is violated, a ValidationException is thrown from build().

You can also validate without throwing:

var card = CardNumber.newBuilder()
    .setDigits("invalid")
    .buildPartial(); <- No validation.
var optionalError = card.validate();
optionalError.ifPresent(err -> {
    System.out.println(err.getMessage());
});

Validation Options

Validation options are defined by the following files:

  1. options.proto.
  2. time_options.proto.

Users must import these .proto files to use the options they define.

import "spine/options.proto"; // Brings all options, except for time-related ones.
import "spine/time_options.proto"; // Brings time-related options.

Architecture

The library is a set of plugins for ProtoData.

Each target language is a separate ProtoData plugin.

Take a look at the following diagram to grasp a high-level library structure:

High-level library structure overview

The workflow is the following:

  • (1), (2) – user defines Protobuf messages with validation options.
  • (3) – Protobuf compiler generates Java classes.
  • (4), (5) – policies and views build the validation model.
  • (6), (7) – Java plugin generates and injects validation code.

Key Modules

Module Description
:model The language-agnostic model for the built-in options.
:java Generates and injects Java validation code based on applied options.
:java-api Extension API for custom options in Java.

Extending the Library

Users can extend the library by providing custom Protobuf options and code generation logic.

Follow these steps to create a custom option:

  1. Declare a Protobuf extension in your .proto file.
  2. Register it via io.spine.option.OptionsProvider.
  3. Implement the following entities:
    • Policy (MyOptionPolicy) – discovers and validates the option.
    • View (MyOptionView) – accumulates valid option applications.
    • Generator (MyOptionGenerator) – generates Java code for the option.
  4. Register them via io.spine.validation.api.ValidationOption.

Below is a workflow diagram for a typical option:

Typical custom option

Take a look at the :java-tests:extensions module that contains a full example of implementation of the custom (currency) option.

Note that a custom option can provide several policies and views, but only one generator. This allows building more complex models, using more entities and events.

Let's take a closer look at each entity.

Policy

Usually, this is an entry point to the option handling.

The policy subscribes to one of *OptionDiscovered events:

  • FileOptionDiscovered.
  • MessageOptionDiscovered.
  • FieldOptionDiscovered.
  • OneofOptionDiscovered.

It filters incoming events, taking only those who contain the option of the interest. The policy may validate the option application, query TypeSystem, extract and transform data arrived with the option, if any. Once ready, it emits an event signaling that the discovered option is valid and ready for the code generation.

The policy may report a compilation warning or an error, failing the whole compilation if it finds an illegal application of the option.

For example:

  1. An unsupported field type.
  2. Illegal option content (invalid regex, parameter, signature).

The policy may just ignore the discovered option and emit NoReaction. A typical example of this is a boolean option, such as (required), which does nothing when it is set to false.

The desired behavior depends on the option itself.

View

Views accumulate events from policies, serving as data providers for the validation model used by code generators. Views are typically simple and only accumulate data; for more complex logic, use policies.

Usually, one view represents a single application of an option.

Generator

The generator is an entity that provides an actual implementation of the option behavior. The generator produces Java code for every application of that option within the message type.

It has access to the Querying interface and can query views to find those belonging to the processed message type.

About

No description, website, or topics provided.

Resources

License

Code of conduct

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors 4

  •  
  •  
  •  
  •