Consolidating Input and Output Validation and Sanitization in Elixir with GuardedStruct library
Implementing an infrastructure for validating, sanitizing, and constructing inputs and outputs in Elixir ensures seamless integration and enhances the security of data received from APIs.
Data integrity at the initial input stage is rarely straightforward, especially when diverse business logic requirements burden developers. Over time, this can lead to chaos in managing data structures. Another challenge lies in the libraries and tools available for handling these needs. To make them comprehensible and cover more complex requirements, they often resort to macros, which come with their own set of advantages and drawbacks—factors that may influence developers' preferences.
A prime example of such an infrastructure is the well-known Elixir library, Ecto. It demonstrates how to create a pipeline that processes input data from the start, cleanses it, and delivers a reliable output. This cleaned data can then be confidently stored in a database or displayed on a website.
Support Our Work
98% of Mishka's products are open source, and we need your support to keep growing stronger.
Every contribution makes a difference and is deeply appreciated! ❤️
The GuardedStruct library follows a similar path. It offers features such as builders, validation, and sanitization, enabling you to clean your data at the infrastructure level based on your business logic. Once processed, the data can be utilized across your application. Notably, during its development, the focus was on making it accessible even to project managers with limited Elixir knowledge. This ensures they can quickly build an MVP for their desired functionality with minimal learning curve.
Useful Links for This Article:
Why Did We Create the GuardedStruct Library?
In my personal experience, macros often feel like a magical black box. They can make a library difficult to understand, even for its creator, especially if the library relies heavily on macros. The lack of transparency in what happens under the hood requires a significant amount of investigation and study. This complexity was a key motivator behind our decision to build the GuardedStruct library, and sharing the story of how it came to be may clarify its purpose and usefulness.
About three years ago, the Mishka team participated in a private academic event. At the time, platforms like Bluesky and Mastodon had not yet gained widespread popularity. Our goal was to utilize the ActivityPub protocol to build a decentralized system for managing small restaurants using Telegram and Cloudflare's free services. We envisioned a hybrid system with a JavaScript-based frontend and backend to maximize the use of Cloudflare's free-tier capabilities. Additionally, the initial website (not a main platform but one focused on connecting users by proximity) was built using Elixir.
Upon delving into the ActivityPub protocol, we realized that each payload—whether exchanged
between federation servers or sent from client to user—came in multiple formats. Worse,
these formats had interdependencies between various fields. We started implementing one
of these payloads with simple if
and else
statements. It quickly became apparent that
even for a single section, we were writing at least 200 lines of code. Breaking it into
smaller functions didn’t reduce the complexity significantly, as the sheer volume made it
hard to maintain control. Non-technical contributors or those unfamiliar with Elixir found
it nearly impossible to provide input during the research and development phases.
And that was just the beginning. We hadn’t yet encountered the deeply nested data structures inherent to the ActivityPub protocol. Over time, we optimized the library for each payload type until, about a year ago, the library was fully integrated into another project. Recently, we decided to separate it into its own independent project, giving rise to GuardedStruct.
Before Diving into the Features, Let’s Examine a Simple Key in ActivityPub
To better understand the challenges and how GuardedStruct addresses them, let’s take a look at a straightforward example of an ActivityPub payload. Even in its simplest form, the data can have multiple structures, as seen below:
Example 1: Basic Payload:
{ "@context": "https://www.w3.org/ns/activitystreams", "summary": "Sally added an object", "type": "Add", "actor": { "type": "Person", "name": "Sally" }, "object": "http://example.org/abc" }
Example 2: Expanded Payload:
{ "@context": "https://www.w3.org/ns/activitystreams", "summary": "Sally added a picture of her cat to her cat picture collection", "type": "Add", "actor": { "type": "Person", "name": "Sally" }, "object": { "type": "Image", "name": "A picture of my cat", "url": "http://example.org/img/cat.png" }, "origin": { "type": "Collection", "name": "Camera Roll" }, "target": { "type": "Collection", "name": "My Cat Pictures" } }
These two payloads showcase how the same key, such as object, can vary significantly in structure. The first example uses a simple URL string for the object field, while the second example includes a nested structure with multiple properties. Additionally, new fields like origin and target appear in the second example, further increasing the complexity.
This variability makes processing and validating data tricky, especially in dynamic systems where payloads constantly evolve. Handling such cases effectively is a core challenge that GuardedStruct is designed to solve.
As you can see, while the API events and actions remain consistent, the data payloads can vary significantly in structure. In some cases, a single action can have up to five completely different data variations. Regardless of whether you're using a database like MongoDB or PostgreSQL, you must validate the data before storage. This validation is crucial both for adhering to business logic and ensuring data security and consistency.
This is precisely where GuardedStruct comes to your aid. It simplifies the process of validating and structuring your data to meet your application's requirements.
For testing and exploring the library, we’ve also prepared a sample implementation of this protocol (albeit a limited one), which you can check out here .
How Does the GuardedStruct Library Work?
At its core, GuardedStruct focuses on three main functionalities: Validation, Sanitization, and Construction (or Builder). Here's a breakdown of how these features work together:
Example Usage of GuardedStruct
Here’s how you can work with GuardedStruct using built-in functions and macros. The library provides helper functions that simplify handling and validating keys within your struct. For example:
These utilities are pre-configured and can be tested or modified using the LiveBook example provided in the documentation. You can access it here .
Creating a Simple Struct with a Builder
To get started, you only need to define a module and use the GuardedStruct macro like so:
defmodule NormalStruct do use GuardedStruct guardedstruct do field(:field_one, String.t()) field(:field_two, integer(), enforce: true) field(:field_three, boolean(), enforce: true) field(:field_four, atom(), default: :hey) end end
Example Usage
Once defined, you can create and initialize the struct anywhere in your application:
NormalStruct.builder(%{field_one: "Mishka", field_two: 1, field_three: true})
Good News for Elixir Developers
GuardedStruct adheres to Elixir’s native concepts for working with structs,
such as
enforce
,
opaque
, and
module
,
while providing additional features
to enhance flexibility and safety. This allows you to use familiar patterns with powerful
extensions, which we’ll explore further in the next sections.
How to Define Validation for Each Field in GuardedStruct
When starting the GuardedStruct project, we initially forked the
typed_struct
library.
However, it quickly became clear that the library’s creator no longer
intended to maintain or update it, and its limited functionality
couldn’t meet our requirements.
Inspired by more advanced libraries in other languages,
especially Rust
and Scala
, we designed a flexible and customizable validation system.
The goal was to give developers full control over field validation without enforcing overly strict rules. Let’s explore how you can define custom validation for each field:
Validation Rules
You can define a validator for any field. The basic requirement is that the input should consist of two elements:
The output must follow this format:
Example Code
defmodule NormalValidatorStruct do use GuardedStruct # Define a struct with fields and attach a custom validator to the `:name` field guardedstruct do field(:name, String.t(), validator: {NormalValidatorStruct, :validator}) field(:title, String.t()) # No validator here for simplicity end # Custom validator for the `:name` field def validator(:name, value) do if is_binary(value), do: {:ok, :name, value}, else: {:error, :name, "Not right!"} end # A default validator for testing purposes (not called in this example) def validator(field_name, value) do {:ok, field_name, value} end end
Key Features
A Practical Workflow
With this approach, GuardedStruct ensures your data validation logic remains clean, modular,
and powerful, inspired by the robust practices of languages like Rust
.
Can You Modify the Final Output After All Validations?
Absolutely!
GuardedStruct
is designed with flexibility in mind. While macros inherently
introduce some structure, this library ensures that users aren’t restricted to predefined features.
For this reason, a
main_validator
can be added to
the parent macro
guardedstruct
.
This allows you to define a function that runs additional validation or transformations on the final output
after all field-level
validations are applied.
If you don’t specify a
main_validator
in the macro, the library will look for it in the module.
This approach keeps your code modular and adaptable.
Example: Applying a Main Validator
defmodule NormalMainValidatorStruct do use GuardedStruct # Attach a main_validator to the struct guardedstruct main_validator: {__MODULE__, :main_validator} do field(:name, String.t()) field(:title, String.t()) end # Define the main_validator function def main_validator(value) do if Map.get(value, :title) == "mishka" do {:ok, value} else {:error, [%{message: "Not right!", field: :title, action: :validator}]} end end end
Key Features of Main Validator
The practical workflow for validation involves several key steps. First, define field-level
validators by using individual
validator
functions to handle specific rules
for each field. Then, apply the
main_validator
for cross-field validation
or any transformations required on the entire struct. Finally, ensure that the
main_validator
returns clear and structured results, either as success
{:ok, value}
or
error responses
{:error, [...]}
, to facilitate
seamless integration into your application.
Defining Validation and Sanitizers with the Derive Concept in GuardedStruct
The
derive
concept in GuardedStruct, inspired by Rust
, allows you to apply both sanitization
and validation in a streamlined and declarative manner. This approach ensures that data passes through
sanitization first before undergoing validation, highlighting errors for any unmet conditions.
Example: Using Derive for Sanitization and Validation
defmodule NormalDeriveStruct do use GuardedStruct guardedstruct do field(:id, integer(), derive: "sanitize(trim) validate(integer, max_len=20, min_len=5)") field(:title, String.t(), derive: "sanitize(trim, upcase) validate(not_empty_string)") field(:name, String.t(), derive: "sanitize(trim, capitalize) validate(string, not_empty, max_len=20)" ) end end
This approach offers several benefits. It enhances control by allowing developers to introduce complex validation logic without needing to modify individual fields. It also promotes modularity by keeping validation logic in separate functions or modules, ensuring that your code remains clean and maintainable. Additionally, the standardized output format provides consistency, allowing errors to be handled uniformly throughout the application.
By using the main_validator, GuardedStruct empowers you to enforce comprehensive and customizable validation for your data, all while maintaining the flexibility and simplicity that Elixir developers love.
Breakdown of the Derive Workflow
The Derive Workflow involves a step-by-step process for sanitizing and validating fields. For the :id
field, the system
sanitizes the input by trimming leading and trailing whitespace. Validation ensures that the value
is an integer and falls within a range of 5 to 20.
The
:title
field undergoes sanitization by removing spaces with trim and converting the string
to uppercase using upcase.
Validation checks that the field is a
non-empty
string. Similarly,
the
:name
field is sanitized by trimming whitespace and
capitalizing the first letter of the string. Validation ensures it is a
non-empty
string with a maximum length of 20 characters.
If any condition fails during the process, the system generates an error message indicating the specific issue.
Sanitize
How to use | Dependencies | Description |
---|---|---|
"sanitize(trim)" | NO | Trim your string |
"sanitize(upcase)" | NO | Upcase your string |
"sanitize(downcase)" | NO | Downcase your string |
"sanitize(capitalize)" | NO | Capitalize your string |
"sanitize(basic_html)" | :html_sanitize_ex | Sanitize your string base on basic_html |
"sanitize(html5)" | :html_sanitize_ex | Sanitize your string base on html5 |
"sanitize(markdown_html)" | :html_sanitize_ex | Sanitize your string base on markdown_html |
"sanitize(strip_tags)" | :html_sanitize_ex | Sanitize your string base on strip_tags |
"sanitize(tag)" | :html_sanitize_ex | Sanitize your string base on html_sanitize_ex selection |
"sanitize(string_float)" | :html_sanitize_ex or NO | Sanitize your string base on html_sanitize_ex and Float.parse/1 |
"sanitize(string_integer)" | :html_sanitize_ex or NO | Sanitize your string base on html_sanitize_ex and Integer.parse/1 |
Validate
How to use | Dependencies | Description |
---|---|---|
"validate(string)" | NO | Validate if the data is string |
"validate(integer)" | NO | Validate if the data is integer |
"validate(list)" | NO | Validate if the data is list |
"validate(atom)" | NO | Validate if the data is atom |
"validate(bitstring)" | NO | Validate if the data is bitstring |
"validate(boolean)" | NO | Validate if the data is boolean |
"validate(exception)" | NO | Validate if the data is exception |
"validate(float)" | NO | Validate if the data is float |
"validate(function)" | NO | Validate if the data is function |
"validate(map)" | NO | Validate if the data is map |
"validate(nil_value)" | NO | Validate if the data is nil value |
"validate(not_nil_value)" | NO | Validate if the data is not nil value |
"validate(number)" | NO | Validate if the data is number |
"validate(pid)" | NO | Validate if the data is Elixir pid |
"validate(port)" | NO | Validate if the data is Elixir port |
"validate(reference)" | NO | Validate if the data is Elixir reference |
"validate(struct)" | NO | Validate if the data is struct |
"validate(tuple)" | NO | Validate if the data is tuple |
"validate(not_empty)" | NO | Validate if the data is not empty - binary, map, list |
"validate(max_len=10)" | NO | Validate if the data is more than 10 - Range, integer, binary |
"validate(min_len=10)" | NO | Validate if the data is less than 10 - Range, integer, binary |
"validate(url)" | NO | Validate if the data is url |
"validate(geo_url)" | ex_url | Validate if the data is geo url |
"validate(tell)" | ex_url | Validate if the data is tell |
"validate(tell=98)" | ex_url | Validate if the data is tell with country code |
"validate(email)" | email_checker | Validate if the data is email |
"validate(location)" | ex_url | Validate if the data is location |
"validate(string_boolean)" | NO | Validate if the data is string boolean |
"validate(datetime)" | NO | Validate if the data is datetime |
"validate(range)" | NO | Validate if the data is datetime |
"validate(date)" | NO | Validate if the data is datetime |
"validate(regex='^[a-zA-Z]+@mishka\.group$')" | NO | Validate if the data is match with regex |
"validate(ipv4)" | NO | Validate if the data is ipv4 |
"validate(not_empty_string)" | NO | Validate if the data is not empty string |
"validate(uuid)" | NO | Validate if the data is uuid |
"validate(enum=String[admin::user::banned])" | NO | Validate if the data is one of the enum value, which is String |
"validate(enum=Atom[admin::user::banned])" | NO | Validate if the data is one of the enum value, which is Atom |
"validate(enum=Integer[1::2::3])" | NO | Validate if the data is one of the enum value, which is Integer |
"validate(enum=Float[1.5::2.0::4.5])" | NO | Validate if the data is one of the enum value, which is Float |
"validate(enum=Map[%{status: 1}::%{status: 2}::%{status: 3}])" | NO | Validate if the data is one of the enum value, which is Map |
"validate(enum=Tuple[{:admin, 1}::{:user, 2}::{:banned, 3}])" | NO | Validate if the data is one of the enum value, which is Tuple |
"validate(equal=some_thing)" | NO | Validate if the data is equal with validation value, which is any type |
"validate(either=[string, enum=Integer[1::2::3]])" | NO | Validate if the data is valid with each derive validation |
"validate(custom=[Enum, all?])" | NO | Validate if the you custom function returns trueو Please read section 20 |
"validate(some_string_float)" | NO | Validate if the string data is float (Somewhat by removing the string) |
"validate(string_float)" | NO | Validate if the string data is float (Strict mode) |
"validate(string_integer)" | NO | Validate if the string data is integer (Strict mode) |
"validate(some_string_integer)" | NO | Validate if the string data is integer (Somewhat by removing the string) |
"validate(not_flatten_empty)" | NO | Validate the list if it is empty by summing and flattening the entire list |
"validate(not_flatten_empty_item)" | NO | Validate the list if it is empty by summing and flattening the entire list and first level children |
"validate(queue)" | NO | Validate the data is Erlang queue or not |
"validate(username)" | NO | Validate the input has username format or not |
"validate(full_name)" | NO | Validate the input has full_name format or not |
Extending Derive Functionality
If you need custom sanitizers or validators, you can extend the derive system by configuring the project to include your modules. Here's how:
Step 1: Add Custom Modules to Your Configuration
Application.put_env(:guarded_struct, :validate_derive, [TestValidate, TestValidate2]) Application.put_env(:guarded_struct, :sanitize_derive, [TestSanitize, TestSanitize2]) # OR Application.put_env(:guarded_struct, :validate_derive, TestValidate) Application.put_env(:guarded_struct, :sanitize_derive, TestSanitize)
Step 2: Create the Custom Modules (Custom Validators)
defmodule TestValidateModule do def validate(:testv1, input, field) do if is_binary(input), do: input, else: {:error, field, :testv1, "The #{field} field must not be empty"} end end defmodule TestValidateModule2 do def validate(:testv2, input, field) do if is_binary(input), do: input, else: {:error, field, :testv1, "The #{field} field must not be empty"} end end
Custom Sanitizers
defmodule TestSanitizeModuleOne do def sanitize(:capitalize_v1, input) do if is_binary(input), do: String.capitalize(input) <> "::_v1", else: input end end defmodule TestSanitizeModuleTwo do def sanitize(:capitalize_v2, input) do if is_binary(input), do: String.capitalize(input) <> "::_v2", else: input end end
Key Considerations
The library distinguishes between Core Derives and Custom Derives to streamline functionality.
Core derives prioritize built-in derive names, while custom derives require unique names to avoid conflicts with existing functionality.
To enhance clarity and modularity, the library offers a declarative configuration approach through the derive
attribute, simplifying the setup of both sanitizers and validators. When validation errors occur,
they are reported in a standardized format, such as
{:error, [%{message: "Not right!", field: :title, action: :validator}]}
,
ensuring consistency and ease of integration. Additionally, the derive system is designed for composability, enabling developers
to combine sanitizers and validators flexibly, even for nested or complex data structures.
Summary
By using the derive concept in GuardedStruct, you gain a powerful and extensible system for sanitizing and validating your data. Whether you stick to the core features or extend it with custom logic, GuardedStruct enables a clean, declarative approach inspired by Rust while fully leveraging Elixir’s flexibility.
Can You Combine Derive with Custom Validators?
Yes, you can combine derive with custom validators in GuardedStruct. The system prioritizes validators first and only applies derive rules once the data passes all validator checks. This layered approach ensures robust validation while allowing you to benefit from the declarative power of derive for sanitization and additional validation.
Example: Combining Derive and Custom Validators
defmodule NormalSimultaneouslyDeriveAndValidatorStruct do use GuardedStruct guardedstruct do field(:name, String.t(), enforce: true, derive: "sanitize(trim, upcase) validate(not_empty)" ) field(:title, String.t(), derive: "sanitize(trim, capitalize) validate(not_empty)") end # Custom validator for the :name field def validator(:name, value) do if is_binary(value), do: {:ok, :name, "Mishka "}, else: {:error, :name, "No, never"} end # Default validator def validator(name, value) do {:ok, name, value} end end # Valid Input: NormalSimultaneouslyDeriveAndValidatorStruct.builder(%{name: "mishka", title: "developer"}) # Result: %NormalSimultaneouslyDeriveAndValidatorStruct{ name: "MISHKA", title: "Developer" } # Invalid Input: NormalSimultaneouslyDeriveAndValidatorStruct.builder(%{name: 123, title: ""}) # Result: {:error, [%{message: "No, never", field: :name, action: :validator}]}
Workflow Breakdown
The workflow for validation follows a structured process. For fields with a custom validator, such as :name,
the custom validation function (
validator(:name, value)
) is executed first. If the custom
validator
succeeds,
the sanitized and validated value is returned, for example:
{:ok, :name, "Mishka "}
.
However, if it fails, an error is immediately returned, halting further processing, such as
{:error, :name, "No, never"}
.
Once the custom validator passes, the derive logic is applied to the value. For the
:name
field,
the sanitization step trims whitespace and converts the string to uppercase, while the validation step ensures the string is not empty.
The process follows a specific order: first, the custom validator is executed, and if it succeeds, the derive sanitizers and validators are applied.
For fields without a custom validator, like
:title
, only the derive sanitizers and validators are
applied, simplifying the workflow for such cases. This structure ensures both flexibility and consistency in handling field-specific rules.
Workflow Breakdown
By combining derive and custom validators, GuardedStruct enables both flexibility and consistency, making it a powerful tool for handling complex data validation workflows.
Analyzing and Utilizing Complex Nested Structs with GuardedStruct
The GuardedStruct library enables developers to handle deeply nested and complex data structures efficiently,
offering powerful tools to define, validate, and sanitize fields in a production-ready manner. Let’s break down the
example of the
NestedStruct
and highlight how GuardedStruct simplifies the process.
Core Features of the Example
1. Handling Simple Fields
For fields like name, the process is straightforward:
2. Managing Nested Structures
The
sub_field
macro is used to define nested structures, like auth and role. Each
nested field can have its own derive rules, validators, or be marked as mandatory.
Example: The auth field includes:
3. Flexible Validators
The validator function provides additional flexibility for fields requiring custom logic.
Example: The nickname
field in the profile struct is validated using a custom function.
def validator(:nickname, value) do if is_binary(value), do: {:ok, :nickname, value}, else: {:error, :nickname, "Invalid nickname"} end
4. Complex Derive Rules
Fields can include multiple sanitization and validation rules within a single derive statement. For example:
derive: "sanitize(strip_tags, trim, capitalize) validate(string, not_empty, max_len=20, min_len=3)"
Example Input
# Processed Output -- Valid Data: {:ok, %NestedStruct{ username: "Mishka", profile: %NestedStruct.Profile{ nickname: "mishka", site: "https://elixir-lang.org" }, auth: %NestedStruct.Auth{ last_activity: "2023-08-20 16:54:07.841434Z", role: %NestedStruct.Auth.Role{ action: "true", name: :user, status: %{status: 2} }, identity_provider: "google", server: "[email protected]" }, age: 18, family: "Group", name: "Mishka" }} # Error Output: If any validation fails, the error is returned in a structured format: {:error, [ %{field: :profile, errors: [%{message: "Invalid nickname", field: :nickname}]}, %{ field: :auth, errors: [ %{ message: "Invalid DateTime format in the last_activity field", field: :last_activity, action: :datetime }, %{ field: :role, errors: [ %{ message: "Invalid boolean format in the action field", field: :action, action: :string_boolean } ] } ] } ]}
Benefits of GuardedStruct
Conclusion
With GuardedStruct, you can effortlessly handle deeply nested data structures, apply robust validation rules, and enforce consistent data policies. The combination of declarative macros, custom validators, and extensible derive features makes it a powerful tool for building production-grade APIs in Elixir.
Does It Look Amazing to You?
Isn’t it beautiful? With so many fields, defining a struct alone would typically take at least 5 minutes, and setting up dependencies could take several hours. Yet, with GuardedStruct, you can accomplish all of this—including preparing it for production—in less than 20 minutes.
But the good news doesn’t stop here. In the following sections, we’ll introduce even more key features that can reduce tasks that usually take days to just under an hour.
Can Error Handling in GuardedStruct Be Combined with
defexception
?
Yes, you can simply add the error: true parameter to the parent macro (
guardedstruct
),
and during compile-time, the macro automatically creates a module for handling errors in the same module where it is defined.
If your macro includes
sub_field
or nested fields, a separate module is created for each nested section.
For example:
Here’s the sample code:
defmodule TestCallNestedStructWithError do use GuardedStruct guardedstruct error: true do field(:name, String.t(), derive: "validate(string)") sub_field(:auth, struct(), error: true) do field(:action, String.t(), derive: "validate(not_empty)") sub_field(:path, struct(), error: true) do field(:name, String.t()) end end end end
Have You Wondered Why a Module Is Created for Each
sub_field
?
This is one of the key features that makes nested structs highly reusable across different parts of your application.
Imagine you want to use only the struct for the
path
field (which itself contains multiple keys and children) in
another part of your app. Instead of using the entire global
builder
,
you can create separate builders for each nested field.
Isn’t that amazing? It maximizes your productivity by allowing each nested section to be independently modularized and reusable in its own context.
How Can You Enforce Maximum Limitation on User Input?
Let’s explore another feature of this library: the authorized_fields option. This option lets you explicitly limit the fields a user can input, adding an extra layer of control. Additionally, we’ll compare this feature with enforce to clarify their differences.
If this option is not enabled, any undefined fields will be automatically ignored by the program. However, when this option is active, it will return an error if the user includes a field that is not in the specified list. Keep in mind that this behavior differs from required_fields, as validation for mandatory fields occurs only after this step.
Security Note: To ensure the correctness of this functionality, you must accurately specify each field name as a map key. For instance, in frameworks like Phoenix, additional care should be taken to validate field names, as neglecting this step could lead to security vulnerabilities.
Example
defmodule TestAuthorizeKeys do use GuardedStruct guardedstruct authorized_fields: true do field(:name, String.t(), derive: "validate(string)") sub_field(:auth, struct(), authorized_fields: true) do field(:action, String.t(), derive: "validate(not_empty)") sub_field(:path, struct()) do field(:name, String.t()) end end end end
Calling Structs from Other Modules
In GuardedStruct, we've made sure that large projects with more modularized codebases can easily use this library without any limitations. With this feature, you can call and reuse fields from another module, providing greater flexibility in managing data flow across your project.
Example Code: Reusing Structs Across Modules
defmodule ExternalAuthModuleStruct do use GuardedStruct guardedstruct do field(:action, String.t(), derive: "validate(not_empty)") end end defmodule ExternalAuthCallModuleStruct do use GuardedStruct guardedstruct do field(:name, String.t(), derive: "validate(string)") field(:auth_path, struct(), struct: ExternalAuthModuleStruct) field(:auth_path1, struct(), structs: ExternalAuthModuleStruct) end end
In this example:
Input and Builder
Using this setup, the input format and builder function remain straightforward:
ExternalAuthCallModuleStruct.builder(%{ name: "Mishka", auth_path: %{action: "add"}, auth_path1: [%{action: "add"}] }) # Result: %ExternalAuthCallModuleStruct{ name: "Mishka", auth_path: %ExternalAuthModuleStruct{action: "add"}, auth_path1: [%ExternalAuthModuleStruct{action: "add"}] }
Key Benefits
Example: Handling Lists with Map Structs
Let’s look at how you can pass a list of data to a struct that only supports maps, without altering the external module:
ExternalAuthCallModuleStruct.builder(%{ name: "Mishka", auth_path: %{action: "add"}, auth_path1: [%{action: "add"}, %{action: "remove"}] })
This flexibility means you can adapt the struct behavior to your specific needs without changing the core logic of the external module being called.
Conclusion
This feature provides immense flexibility for large-scale projects, allowing for the modularization and reuse of structs across different modules. It simplifies the management of nested and external data while ensuring seamless integration with GuardedStruct's builder functionality.
Example: Nested Fields with Lists and External Structs
In GuardedStruct, you can handle more complex scenarios, such as lists of nested structs or combining external structs with nested fields. Here’s an example that demonstrates the flexibility of this approach:
defmodule ListSubFieldAndExternalAuthCallModuleStruct do use GuardedStruct guardedstruct do field(:name, String.t(), derive: "validate(not_empty)") field(:auth_path, struct(), structs: ExternalAuthModuleStruct) sub_field(:profile, list(struct()), structs: true) do field(:github, String.t(), enforce: true, derive: "validate(url)") field(:nickname, String.t(), derive: "validate(not_empty)") end end end
Example Input
Here’s how you can handle this struct in your API or function inputs:
# Input Example 1: ListSubFieldAndExternalAuthCallModuleStruct.builder(%{ name: "mishka", auth_path: [ %{action: "*:admin", path: %{role: "1"}}, %{action: "*:user", path: %{role: "3"}} ] }) # Input Example 2: ListSubFieldAndExternalAuthCallModuleStruct.builder(%{ name: "mishka", auth_path: [ %{action: "*:admin", path: %{role: "1"}}, %{action: "*:user", path: %{role: "3", rel: %{social: "github"}}} ], profile: [%{github: "https://github.com/mishka-group"}] })
Why Is This So Exciting?
I often find myself thrilled when working with the simplicity and power of the features I’ve built into this library. Starting a project or implementing its logic used to feel overwhelming and time-consuming. But now, with all the capabilities mentioned, there’s no excuse for delaying the start of a new project or adding a new feature.
What’s Next?
We haven’t even touched on some of the more advanced and unique features of this project yet. I guarantee that as we delve deeper, you’ll start imagining countless possibilities and implementations. You can explore the entire feature set in the documentation. While this article aims to cover the highlights, it’s impossible to dive deeply into every feature in one place. For detailed exploration, the documentation is your best resource.
Can a Field Be Automatically Revalued?
Yes,
GuardedStruct
introduces the Auto Core Key feature, which goes beyond the standard default functionality.
Unlike default, which assigns a value when the user omits a field, Auto Core Key disregards the user's input entirely—even
if the field is included in the input—and assigns a value based on a custom function.
For example, you may want an ID field to always generate a random string, regardless of whether the user provides an input. This feature is especially useful for ensuring consistent data generation.
Example: Auto Core Key Implementation
defmodule AutoCoreKeyStruct do use GuardedStruct guardedstruct do field(:username, String.t(), derive: "validate(not_empty)") field(:user_id, String.t(), auto: {Ecto.UUID, :generate}) field(:parent_id, String.t(), auto: {Ecto.UUID, :generate}) sub_field(:profile, struct()) do field(:id, String.t(), auto: {Ecto.UUID, :generate}) field(:nickname, String.t(), derive: "validate(not_empty)") sub_field(:social, struct()) do field(:id, String.t(), auto: {AutoCoreKeyStruct, :create_uuid, "test-path"}) field(:skype, String.t(), derive: "validate(string)") field(:username, String.t(), from: "root::username") end end sub_field(:items, struct(), structs: true) do field(:id, String.t(), auto: {Ecto.UUID, :generate}) field(:something, String.t(), derive: "validate(string)", from: "root::username") end end # Custom UUID generator def create_uuid(default) do MishkaDeveloperTools.Helper.UUID.generate() <> "-#{default}" end end
How Does It Work?
Example Input and Output
# Example 1: Auto Value Ignored for user_id AutoCoreKeyStruct.builder(%{username: "mishka", user_id: "test_not_to_be_replaced"}) # Result: %AutoCoreKeyStruct{ username: "mishka", user_id: "generated-uuid", parent_id: "generated-uuid", ... } # Example 2: Auto Value Considered for Editing (:edit) AutoCoreKeyStruct.builder({:root, %{username: "mishka", user_id: "test_should_be_replaced"}, :edit}) # Result: %AutoCoreKeyStruct{ username: "mishka", user_id: "test_should_be_replaced", parent_id: "generated-uuid", ... }
Key Features of Auto Core Key
Why Is This Important?
This feature not only streamlines data handling but also minimizes potential security risks by removing reliance on user-provided inputs for
critical fields. The ability to customize and override behavior for specific operations (e.g.,
:edit
)
adds a layer of flexibility that is crucial for real-world applications.
If you're exploring this feature further, remember that this is just one of many powerful options available in GuardedStruct. For a full list of capabilities, refer to the project documentation (click here ).
How Can You Create Dependent Fields?
One way to achieve dependent fields is by using the features we’ve discussed so far, such as custom validators and other tools.
However, GuardedStruct introduces a specific concept called
On
Core Key,
designed to make this process simpler and more declarative.
What Is the On Core Key Concept?
Using On Core Key, you can make one field dependent on the presence or value of another field. If the dependency isn’t met, an error message is generated. This allows you to easily enforce relationships between fields in your data structures.
How Does Routing Work?
When defining dependencies with On Core Key, the routing of data behaves differently depending on the configuration:
Trade-Off of Using On Core Key
Using this feature comes with a trade-off. When core keys are called, the entire primary map is sent to each child for processing. This can increase memory usage depending on the size of the map, so it should be used thoughtfully in performance-critical scenarios.
defmodule OnCoreKeyStruct do use GuardedStruct guardedstruct do field(:name, String.t(), derive: "validate(string)") sub_field(:profile, struct()) do field(:id, String.t(), auto: {Ecto.UUID, :generate}) field(:nickname, String.t(), on: "root::name", derive: "validate(string)") field(:github, String.t(), derive: "validate(string)") sub_field(:identity, struct()) do field(:provider, String.t(), on: "root::profile::github", derive: "validate(string)") field(:id, String.t(), auto: {Ecto.UUID, :generate}) field(:rel, String.t(), on: "sub_identity::auth_path::action") sub_field(:sub_identity, struct()) do field(:id, String.t(), auto: {Ecto.UUID, :generate}) field(:auth_path, struct(), struct: ExternalAuthModuleStruct) end end end sub_field(:last_activity, list(struct()), structs: true) do field(:action, String.t(), enforce: true, derive: "validate(string)", on: "root::name") end end end Now you can test like this: OnCoreKeyStruct.builder(%{ name: "mishka", profile: %{ nickname: "Mishka", github: "test", identity: %{ provider: "git", sub_identity: %{id: "test", auth_path: %{action: "admin/edit"}} } } }) # OR OnCoreKeyStruct.builder(%{ name: "mishka", profile: %{ nickname: "Mishka", identity: %{provider: "git"} } })
It's just as simple to navigate between
sub_fields
and implement your logic as needed.
How to Retrieve the Value from Another Field
You might wonder why such a feature is necessary. The reason lies in the complex payloads you'll encounter in a full-fledged project using ActivityPub. This capability helps us build a complete, high-performance project tailored to various requirements.
Note: I must emphasize once again to refer to the documentation linked at the beginning of this article. Each section comes with specific rules and requirements that may not be fully discussed here.
defmodule FromCoreKeyStruct do use GuardedStruct guardedstruct do field(:username, String.t(), derive: "validate(not_empty)") field(:user_id, String.t(), auto: {Ecto.UUID, :generate}) field(:parent_id, String.t(), auto: {Ecto.UUID, :generate}) sub_field(:profile, struct()) do field(:id, String.t(), auto: {Ecto.UUID, :generate}) field(:nickname, String.t(), derive: "validate(not_empty)") sub_field(:social, struct()) do field(:id, String.t(), auto: {FromCoreKeyStruct, :create_uuid, "test-path"}) field(:skype, String.t(), derive: "validate(string)") field(:username, String.t(), from: "root::username") end end sub_field(:items, struct(), structs: true) do field(:id, String.t(), auto: {Ecto.UUID, :generate}) field(:something, String.t(), derive: "validate(string)", from: "root::username") end end def create_uuid(default) do MishkaDeveloperTools.Helper.UUID.generate() <> "-#{default}" end end
As we near the end of this article, you’ll notice that the complexity of the incoming data increases. This leads us to the next concept: Domain Core Key. Sometimes, you move beyond simple logic and into domain-level rules and abstractions.
What is Domain Core Key and How Does It Aggregate Data?
When working with deeply nested structures, there are times when you need to define the acceptable range of values for a set of parameters based on input from a parent. Unlike earlier features, in this section, we do not pass the entire Struct or Map into this feature. Instead, we rely on a top-down structure, often referred to as a parent-to-child relationship.
Example Code: Domain Core Key
defmodule AllowedParentDomain do use GuardedStruct guardedstruct authorized_fields: true do field(:username, String.t(), domain: "!auth.action=String[admin, user]::?auth.social=Atom[banned]", derive: "validate(string)" ) field(:type_social, String.t(), domain: "?auth.type=Map[%{name: \"mishka\"}, %{name: \"mishka2\"}]", derive: "validate(string)" ) sub_field(:auth, struct(), authorized_fields: true) do field(:action, String.t(), derive: "validate(not_empty)") field(:social, atom(), derive: "validate(atom)") field(:type, map(), derive: "validate(map)") end end end
Explanation of Domain Core Key
1. Dependency Between Fields
The domain option allows you to enforce specific rules based on the values of related fields.
For instance:
domain: "!auth.action=String[admin, user]::?auth.social=Atom[banned]"
2. Nested Relationships
The
sub_field
:auth
defines additional rules and
dependencies for
action
,
social
, and
type
.
3. Controlled Input
This approach ensures that child values align with parent-defined constraints, providing a robust way to handle deeply nested data.
Why Is This Feature Powerful?
The Domain Core Key feature allows you to create a pipeline for validating data at every level of a structure, ensuring consistency and reducing the chances of error. With its flexibility, you can handle a variety of data types and complex validation scenarios, making it a key tool for large-scale projects.
Note
This section is incredibly comprehensive, as you can define multiple domains, each with its own set of conditions. For example, the documentation dedicates 7-8 sections to this feature alone. Its high utility warrants even 1-2 dedicated tutorial videos for training purposes.
Final Feature: Conditional Fields
Are you excited yet? If not, you should be, because this feature goes far beyond a simple if-else. Each field can be:
And for each, you can define unique conditions. This allows you to create a pipeline for processing all user-submitted data in a single endpoint.
Benefits for Frontend Teams
This feature is also immensely helpful for frontend teams. It enables them to make better decisions when dealing with a large volume of errors, ensuring smoother workflows and better error management across the application.
What Are Conditional Fields?
One of the unique features of this macro is the ability to define conditional fields that adapt to different types of input. For example, you can configure a field such as social to accept either a string value or a map containing keys like address and provider.
Additionally,
conditional_field
supports nesting, allowing you to define
subfields and complex hierarchies. Below, you'll find an example illustrating this flexibility.
Note on Documentation
When documenting your API's conditional fields, it's crucial to communicate the intended usage to your frontend team.
Use the
hint
keyword to provide clues about how each section should behave.
The output of
hint
will look like this:
hint
.
Example Code: Validators for Conditional Fields
Here’s an example of simple validators used for conditional fields:
defmodule ConditionalFieldValidatorTestValidators do def is_string_data(field, value) do if is_binary(value), do: {:ok, field, value}, else: {:error, field, "It is not a string"} end def is_map_data(field, value) do if is_map(value), do: {:ok, field, value}, else: {:error, field, "It is not a map"} end def is_list_data(field, value) do if is_list(value), do: {:ok, field, value}, else: {:error, field, "It is not a list"} end def is_flat_list_data(field, value) do if is_list(value), do: {:ok, field, List.flatten(value)}, else: {:error, field, "It is not a list"} end def is_int_data(field, value) do if is_integer(value), do: {:ok, field, value}, else: {:error, field, "It is not an integer"} end end
Using Conditional Fields in a Schema
The example below shows how to use the conditional_field macro to define complex nested fields:
defmodule ConditionalFieldComplexTest do use GuardedStruct alias ConditionalFieldValidatorTestValidators, as: VAL guardedstruct do field(:provider, String.t()) sub_field(:profile, struct()) do field(:name, String.t(), enforce: true) field(:family, String.t(), enforce: true) conditional_field(:address, any()) do field(:address, String.t(), hint: "address1", validator: {VAL, :is_string_data}) sub_field(:address, struct(), hint: "address2", validator: {VAL, :is_map_data}) do field(:location, String.t(), enforce: true) field(:text_location, String.t(), enforce: true) end sub_field(:address, struct(), hint: "address3", validator: {VAL, :is_map_data}) do field(:location, String.t(), enforce: true, derive: "validate(string, location)") field(:text_location, String.t(), enforce: true) field(:email, String.t(), enforce: true) end end end conditional_field(:product, any()) do field(:product, String.t(), hint: "product1", validator: {VAL, :is_string_data}) sub_field(:product, struct(), hint: "product2", validator: {VAL, :is_map_data}) do field(:name, String.t(), enforce: true) field(:price, integer(), enforce: true) sub_field(:information, struct()) do field(:creator, String.t(), enforce: true) field(:company, String.t(), enforce: true) conditional_field(:inventory, integer() | struct(), enforce: true) do field(:inventory, integer(), hint: "inventory1", validator: {VAL, :is_int_data}, derive: "validate(integer, max_len=33)" ) sub_field(:inventory, struct(), hint: "inventory2", validator: {VAL, :is_map_data}) do field(:count, integer(), enforce: true) field(:expiration, integer(), enforce: true) end end end end end end end
Example Input Payload
Here’s a sample payload a user can send to the builder function:
ConditionalFieldComplexTest.builder(%{ provider: "Mishka", profile: %{ name: "Shahryar", family: "Tavakkoli", address: %{ location: "geo:48.198634,-16.371648,3.4;crs=wgs84;u=40.0", text_location: "Nowhere", email: "[email protected]" } }, product: %{ name: "MishkaDeveloperTools", price: 0, information: %{ creator: "Shahryar Tavakkoli", company: "Mishka Group", inventory: %{ count: 3_000_000, expiration: 33 } } } })
Flexibility of Conditional Fields
If you review the code, you’ll notice that you can use both validator and derive with conditional fields. This feature’s flexibility allows for complex configurations. For example, by changing just one s, you can switch a single conditional field into a list of conditional fields:
conditional_field(:activities, any(), structs: true) do field(:activities, struct(), struct: ExtrenalConditional, validator: {VAL, :is_map_data}, hint: "activities1") field(:activities, struct(), structs: ExtrenalConditional, validator: {VAL, :is_list_data}, hint: "activities2") field(:activities, String.t(), hint: "activities3", validator: {VAL, :is_string_data}) end
Summary
This schema outlines the extensive capabilities of this macro. While developing and testing, I encountered a problem I couldn’t solve. If you think you can help, I’ve opened an issue for it.
I’m planning to create short videos explaining each feature soon and will share them with you!
If you found this tutorial helpful, please share it on social media!
Support Our Work
98% of Mishka's products are open source, and we need your support to keep growing stronger.
Every contribution makes a difference and is deeply appreciated! ❤️