An open API service indexing awesome lists of open source software.

https://github.com/hakanensari/structure

Turn hashes into data objects
https://github.com/hakanensari/structure

data-modeling ruby value-object

Last synced: 4 months ago
JSON representation

Turn hashes into data objects

Awesome Lists containing this project

README

          

# Structure

[![CI/CD Pipeline](https://github.com/hakanensari/structure/actions/workflows/ci.yml/badge.svg)](https://github.com/hakanensari/structure/actions/workflows/ci.yml)

![Ruby](https://raw.githubusercontent.com/hakanensari/structure/refs/heads/main/images/ruby.png)

**Structure your data**

Turn unruly hashes into clean [Ruby Data](https://docs.ruby-lang.org/en/3.4/Data.html) objects with type coercion.

```ruby
# Before: Hash drilling
user_name = response["user"]["name"]
user_age = response["user"]["age"].to_i
user_active = response["user"]["is_active"] == "true"

# After: Clean, typed objects
user.name # => "Alice" (String)
user.age # => 25 (Integer)
user.active? # => true
```

Built on [Ruby Data](https://docs.ruby-lang.org/en/3.4/Data.html) for immutability, pattern matching, and all the other good stuff. Zero dependencies.

## Installation

Add to your Gemfile:

```ruby
gem "structure"
```

## Usage

### The Basics

```ruby
User = Structure.new do
attribute(:name, String)
attribute(:age, Integer)
attribute(:active, :boolean)
end

user = User.parse({
"name" => "Alice",
"age" => "25",
"active" => "true"
})

user.name # => "Alice" (String)
user.age # => 25 (Integer)
user.active # => true (TrueClass)
user.active? # => true (predicate method)
```

### Type Coercion

Uses Ruby's built-in coercion methods to convert data:

```ruby
Product = Structure.new do
attribute(:title, String) # Uses String(val)
attribute(:price, Float) # Uses Float(val)
attribute(:quantity, Integer) # Uses Integer(val)
attribute(:available, :boolean) # Custom boolean logic
end

product = Product.parse({
"title" => 123,
"price" => "19.99",
"quantity" => "5",
"available" => "1"
})

product.title # => "123"
product.price # => 19.99
product.quantity # => 5
product.available # => true
```

### Key Mapping

Clean up gnarly keys:

```ruby
Person = Structure.new do
attribute(:name, String, from: "full_name")
attribute(:active, :boolean, from: "is_active")
end

person = Person.parse({
"full_name" => "Bob Smith",
"is_active" => "true"
})

person.name # => "Bob Smith"
person.active? # => true
```

### Optional Attributes

Structure wraps Data classes. All attributes are required when creating instances, even if their value is `nil`.

```ruby
User = Structure.new do
attribute(:name, String)
attribute(:age, Integer)
end

User.parse(name: "Alice", age: 30) # Works
User.parse(name: nil, age: nil) # Works, nil values allowed
User.parse(name: "Alice") # ArgumentError: missing keyword: :age
```

Use `attribute?` to make attributes truly optional. The key can then be omitted entirely.

```ruby
User = Structure.new do
attribute(:name, String)
attribute?(:age, Integer)
end

# Now you can omit the optional attribute
User.parse(name: "Bob") # Works, age defaults to nil

# You still must provide regular attributes
User.parse(age: 10) # ArgumentError: missing keyword: :name
```

### Default Values

Handle missing data:

```ruby
Config = Structure.new do
attribute(:timeout, Integer, default: 30)
attribute(:debug, :boolean, default: false)
end

config = Config.parse({}) # Empty data

config.timeout # => 30
config.debug # => false
```

### Array Types

Arrays with automatic element coercion:

```ruby
Order = Structure.new do
attribute(:items, [String])
attribute(:quantities, [Integer])
attribute(:flags, [:boolean])
end

order = Order.parse({
"items" => [123, 456, "hello"],
"quantities" => ["1", "2", 3.5],
"flags" => ["true", 0, 1, "false"]
})

order.items # => ["123", "456", "hello"]
order.quantities # => [1, 2, 3]
order.flags # => [true, false, true, false]
```

### Nested Objects

Compose structures for complex data:

```ruby
Address = Structure.new do
attribute(:street, String)
attribute(:city, String)
end

User = Structure.new do
attribute(:name, String)
attribute(:address, Address)
end

user = User.parse({
"name" => "Alice",
"address" => {
"street" => "123 Main St",
"city" => "Boston"
}
})

user.name # => "Alice"
user.address.street # => "123 Main St"
user.address.city # => "Boston"
```

### Arrays of Objects

Combine array syntax with nested objects:

```ruby
Tag = Structure.new do
attribute(:name, String)
attribute(:color, String)
end

Product = Structure.new do
attribute(:title, String)
attribute(:tags, [Tag])
end

product = Product.parse({
"title" => "Laptop",
"tags" => [
{ "name" => "electronics", "color" => "blue" },
{ "name" => "computers", "color" => "green" }
]
})

product.title # => "Laptop"
product.tags.first.name # => "electronics"
```

### Lazy Resolution

To handle circular dependencies between classes, you can use string class names that are resolved lazily:

```ruby
module MyApp
Order = Structure.new do
attribute(:id, String)
attribute(:items, ["OrderItem"]) # String resolved lazily
attribute(:customer, "Customer") # String resolved lazily
end

OrderItem = Structure.new do
attribute(:name, String)
attribute(:order, "Order") # Circular reference back to Order
end

Customer = Structure.new do
attribute(:name, String)
attribute(:orders, ["Order"]) # Circular reference to Order
end
end

# Works despite circular dependencies
order = MyApp::Order.parse({
"id" => "123",
"customer" => { "name" => "Alice" },
"items" => [{ "name" => "Widget" }]
})

order.customer.name # => "Alice"
order.items.first.name # => "Widget"
```

### Custom Transformations

When you need custom logic:

```ruby
Order = Structure.new do
attribute :price do |value|
Money.new(value["amount"], value["currency"])
end
end

order = Order.parse({
"price" => { "amount" => "29.99", "currency" => "USD" }
})

order.price # => #
```

### Boolean Conversion

Structure follows Rails-style boolean conversion:

**Truthy values:** `true`, `1`, `"1"`, `"t"`, `"T"`, `"true"`, `"TRUE"`, `"on"`, `"ON"`
**Falsy values:** Everything else (including `false`, `0`, `"0"`, `"false"`, `""`, `nil`)

```ruby
User = Structure.new do
attribute(:active, :boolean)
end

User.parse(active: "true").active # => true
User.parse(active: "1").active # => true
User.parse(active: "false").active # => false
User.parse(active: "0").active # => false
User.parse(active: "").active # => false
```

### Supported Types

Structure supports Ruby's kernel coercion methods like `String(val)`, `Integer(val)`, `Float(val)`, etc., plus:

- `:boolean` - Custom Rails-style boolean conversion
- `[Type]` - Arrays with element coercion
- Custom classes with `.parse` method
- Ruby standard library classes with `.parse`, including:
- `Date` - Parses date strings
- `Time` - Parses various time formats
- `URI` - Parses URLs into URI objects

```ruby
Event = Structure.new do
attribute(:name, String)
attribute(:date, Date)
attribute(:starts_at, Time)
attribute(:website, URI)
end

event = Event.parse({
"name" => "RubyConf",
"date" => "2024-12-25",
"starts_at" => "2024-12-25T09:00:00-05:00",
"website" => "https://rubyconf.org"
})

event.date # => #
event.starts_at # => 2024-12-25 09:00:00 -0500
event.website # => #
```

### Custom Types

The type system is flexible. Any object that responds to `.call` (procs, lambdas) or `.parse` (classes) can be used as a type:

```ruby
# Using a lambda for simple transformations
UppercaseString = ->(val) { val.to_s.upcase }

# Using a class with .parse for complex types
class Money
def self.parse(data)
return nil unless data
amount = data.is_a?(Hash) ? data['amount'] : data
new(amount.to_f)
end

def initialize(amount)
@amount = amount
end

attr_reader :amount
end

Product = Structure.new do
attribute :name, UppercaseString
attribute :price, Money
end

product = Product.parse({
"name" => "widget",
"price" => { "amount" => "19.99" }
})

product.name # => "WIDGET"
product.price.amount # => 19.99
```

### Self-Referential Types

Build tree structures and other self-referential data:

```ruby
Tree = Structure.new do
attribute(:id, Integer)
attribute(:name, String)
attribute(:children, [:self], default: [])
end

tree = Tree.parse({
"id" => 1,
"name" => "Electronics",
"children" => [
{ "id" => 2, "name" => "Computers" },
{ "id" => 3, "name" => "Phones", "children" => [
{ "id" => 4, "name" => "Smartphones" }
]}
]
})

tree.name # => "Electronics"
tree.children.first.name # => "Computers"
tree.children[1].children.first.name # => "Smartphones"
```

Use `:self` for single references or `[:self]` for arrays of self-references. Perfect for modeling hierarchical data like navigation menus, comment threads, or organizational charts.

### After Parse Callbacks

Add validation or post-processing logic that runs after parsing:

```ruby
Order = Structure.new do
attribute(:order_id, String)
attribute(:total, Float)

after_parse do |order|
raise "Order ID is required" if order.order_id.nil?
raise "Total must be positive" if order.total && order.total <= 0
end
end

# Raises error for invalid data
Order.parse(total: -10) # => RuntimeError: Total must be positive

# Works fine with valid data
order = Order.parse(order_id: "123", total: 99.99)
order.order_id # => "123"
```

The `after_parse` callback receives the parsed instance and runs after all attributes have been coerced. Any exception raised prevents the instance from being returned.

### Custom Methods

Define instance and class methods directly in the Structure block, just like `Data.define`:

```ruby
User = Structure.new do
attribute(:name, String)
attribute(:age, Integer)
attribute(:active, :boolean)

# Instance methods
def adult?
age >= 18
end

def greeting
"Hello, I'm #{name}"
end

def status
active ? "online" : "offline"
end

# Class methods
def self.create_guest
parse(name: "Guest", age: 0, active: false)
end
end

user = User.parse(name: "Alice", age: 25, active: true)
user.adult? # => true
user.greeting # => "Hello, I'm Alice"
user.status # => "online"

guest = User.create_guest
guest.name # => "Guest"
guest.adult? # => false
```

Custom methods work seamlessly with all Structure features including type coercion, key mapping, defaults, optional attributes, nested structures, and arrays:

```ruby
Product = Structure.new do
attribute(:name, String)
attribute(:price, Float)
attribute(:tags, [String])
attribute?(:discount, Float)

def discounted_price
return price unless discount

price * (1 - discount)
end

def has_tag?(tag)
tags.include?(tag)
end

def self.categories
["electronics", "books", "clothing"]
end
end

product = Product.parse(
name: "Laptop",
price: "999.99",
tags: ["electronics", "computers"],
discount: "0.1"
)

product.discounted_price # => 899.991
product.has_tag?("electronics") # => true
Product.categories # => ["electronics", "books", "clothing"]
```

### RBS Type Signatures

Generate RBS type signatures for your Structure classes:

```ruby
require 'structure/rbs'

User = Structure.new do
attribute(:name, String)
attribute(:age, Integer)
attribute(:tags, [String])
end

# Generate RBS content
Structure::RBS.emit(User)
# => class User < Data
# def self.new: (name: String?, age: Integer?, tags: Array[String]?) -> instance
# def self.parse: (?(Hash[String | Symbol, untyped]), **untyped) -> instance
# attr_reader name: String?
# attr_reader age: Integer?
# attr_reader tags: Array[String]?
# ...
# end

# Write RBS to file
Structure::RBS.write(User, dir: "sig") # => "sig/user.rbs"
```

#### Custom Methods and Steep

`Structure::RBS.emit` generates type signatures for custom methods with parameters and return types defaulting to `untyped`:

```ruby
User = Structure.new do
attribute(:age, Integer)

# steep:ignore:start
def adult?
age >= 18
end
# steep:ignore:end
end

Structure::RBS.emit(User)
# => ...
# def adult?: () -> untyped
# end
```

The generated signatures work for code that uses your Structure classes, but Steep may report warnings in definition files when custom methods are present. This happens because the `Structure.new` block is evaluated in two different contexts at runtime (once for DSL methods like `attribute`, once for custom methods), but Steep can only analyze one static context. Wrap custom methods with `# steep:ignore:start` and `# steep:ignore:end` comments, or exclude definition files from Steep checking in your `Steepfile`.

See also: [RBS Data/Struct documentation](https://github.com/ruby/rbs/blob/master/docs/data_and_struct.md), [RBS issue #654](https://github.com/ruby/rbs/issues/654), [RBS issue #1077](https://github.com/ruby/rbs/issues/1077)

## Development

```bash
$ bundle install
$ bundle exec rbs collection install
$ bundle exec rake
```

### Performance Considerations

String-based method generation with `class_eval` is more performant but also overcomplicates the code. For now, I prioritize legibility.