OdataDuty

OdataDuty is a Ruby gem that lets you define structured data and operations once using a simple DSL — and expose them seamlessly to analytics tools (like PowerBI), no-code platforms (like PowerAutomate), and AI systems (via JSON-RPC or the Model Context Protocol).

It’s designed around the principle of "define once, serve everywhere": you model your entities, properties, filters, and behaviors in Ruby, and OdataDuty takes care of transforming that into formats and protocols your tools and agents understand.


✨ Why use OdataDuty?

  • ✅ Define your data model and logic in plain Ruby
  • ✅ Support schema-based APIs (OpenAPI/Swagger)
  • ✅ Avoid repeating business logic in multiple layers or formats
  • ✅ Build for humans and work with reporting tools, automation tools, and LLMs simultaneously

Installation

Add this line to your application's Gemfile:

gem 'odata_duty'

And then execute:

bundle install

Or install it manually:

gem install odata_duty

Rails Integration

If you're using Rails, you can use the included generators to quickly set up OdataDuty:

  1. Set up the basic OData API structure:
bin/rails generate odata_duty:install
  1. Generate entity types and sets:
bin/rails generate odata_duty:entity_set Product name:string price:decimal category:string

See the Entity Set Generator documentation for more details.


Getting Started

The gem assumes basic familiarity with OData concepts.
If you’re new, check out the OData Crash Course.

🔧 Key Features

  • Entity and property definition using a simple DSL
  • Filtering, paging, and count support ($filter supports implicit and and flat single-operator or, e.g. color eq 'red' or color eq 'blue', via the od_filter_or hook; mixing and/or or using parentheses is not supported)
  • Complex types and enums
  • Individual item retrieval and creation
  • Schema introspection and OpenAPI generation

DSL Quick Example

require 'odata_duty'

class PersonEntity < OdataDuty::EntityType
  property_ref 'id', String
  property 'user_name', String, nullable: false
  property 'name', String
  property 'emails', [String], nullable: false
end

class PeopleSet < OdataDuty::EntitySet
  entity_type PersonEntity

  def od_after_init
    @records = Person.active
  end

  def collection
    @records
  end

  def individual(id)
    @records.find(id)
  end

  def create(data)
    Person.create!(username: data.user_name, name: data.name, emails: data.emails)
  end
end

class SampleSchema < OdataDuty::Schema
  namespace 'SampleSpace'
  entity_sets [PeopleSet]
  base_url Rails.application.routes.url_helpers.api_root_url
end

Implementing create makes a set insertable, update makes it updatable, and delete makes it deletable; omitting them keeps the set read-only. Each choice is reflected automatically across $oas2, $metadata, and MCP — see Using create, update, and delete.

By default the entity-set name comes from the class name: a class named <X>EntitySet or <X>Set exposes entity set <X> (the suffix is stripped), so PeopleSet above serves People. Pass an explicit name/url to override.


Rails Integration Example

You can quickly generate the boilerplate controller, routes and schema with:

bin/rails generate odata_duty:install
# config/routes.rb
scope '/api' do
  root 'api#index'
  get '$metadata' => 'api#metadata'
  get '$oas2' => 'api#oas2'
  get '*url' => 'api#show'
  post '*url' => 'api#create'
end
# app/controllers/api_controller.rb

def index
  render json: OdataDuty::EdmxSchema.index_hash(schema)
end

def 
  render xml: OdataDuty::EdmxSchema.(schema)
end

# `OAS2.build_json` requires a builder-DSL schema (`SchemaBuilder.build`), not a class-based `Schema`.
def oas2
  # CORS header so Power Automate can import `$oas2` from URL — see doc/using_oas2.md.
  response.set_header('Access-Control-Allow-Origin', '*')
  render json: OdataDuty::OAS2.build_json(schema)
end

def show
  render json: schema.execute(params[:url], context: self, query_options: query_options)
end

def create
  render json: schema.create(params[:url], context: self, query_options: query_options)
end

private

def query_options
  params.to_unsafe_hash.except('url', 'action', 'controller', 'format')
end

def schema
  @schema ||= OdataDuty::SchemaBuilder.build(namespace: 'MySpace', host: request.host_with_port,
                                          scheme: request.scheme, base_path: api_index_path) do |s|
    s.title = "My Dynamic API"
    s.version = '0.0.1'
    person_entity = s.add_entity_type(name: 'Person') do |et|
      et.property_ref 'id', String
      et.property 'user_name', String, nullable: false
    end
    s.add_entity_set(url: 'People', entity_type: person_entity,
                      resolver: 'PeopleResolver')
  end
end

In the builder DSL the entity-set name defaults from the resolver: resolver: '<Namespace>::<X>Resolver' with no name: yields entity set <X> (the namespace and the Resolver suffix are stripped). Above, resolver: 'PeopleResolver' would default to People; the explicit url: 'People' sets it directly.

When a builder resolver's collection / individual raises, the error's backtrace is prefixed with the resolver's own method, its od_after_init, and the add_entity_set call site so failures point at your code first.

# app/models/people_resolver.rb
class PeopleResolver < OdataDuty::SetResolver
  def od_after_init
    @records = Person.all
  end

  def od_filter_eq(property_name, value)
    @records = @records.where(property_name.to_sym => value)
  end

  def od_filter_ne(property_name, value)
    @records = @records.where.not(property_name.to_sym => value)
  end

  def od_filter_gt(property_name, value)
    @records = @records.where("#{property_name} > ?", value)
  end

  def od_filter_lt(property_name, value)
    @records = @records.where("#{property_name} < ?", value)
  end

  # Flat single-operator `or`, e.g. `color eq 'red' or color eq 'blue'`.
  # Called once with every or'd predicate. Mixing `and`/`or` and parentheses are not supported.
  def od_filter_or(predicates)
    clauses = predicates.map do |p|
      case p.operation
      when :eq then @records.where(p.property_name => p.value)
      when :ne then @records.where.not(p.property_name => p.value)
      when :gt then @records.where("#{p.property_name} > ?", p.value)
      when :ge then @records.where("#{p.property_name} >= ?", p.value)
      when :lt then @records.where("#{p.property_name} < ?", p.value)
      when :le then @records.where("#{p.property_name} <= ?", p.value)
      else
        raise ArgumentError, "Unsupported filter operation: #{p.operation.inspect}"
      end
    end
    @records = clauses.reduce(:or)
  end

  def count
    @records.count
  end

  def collection
    @records
  end

  def individual(id)
    @records.find { |record| record.id == id }
  end
end

📚 Further Documentation

Documentation convention: Every externally-facing feature (a new DSL option, query option, or protocol surface) ships with a doc/using_*.md guide and a link from the Further Documentation list above. Lead integration examples with the Rails-controller pattern (the primary audience); show raw Rack only as a secondary, runnable reference.


TODO

  • Add support for composite keys
  • Add support for schema descriptions
  • Extend protocol adapters (MCP tools, resource reading)

Development

bin/setup     # Install dependencies
rake spec     # Run the test suite
bin/console   # Open interactive console

Test Server

To run the test server with auto-restart:

bundle exec rerun -- bundle exec rackup spec/config.ru

For MCP debugging with the inspector:

npx @modelcontextprotocol/inspector@0.15.0 -e PORT=9292 bundle exec rackup spec/config.ru

To install this gem locally:

bundle exec rake install

To release a new version:

bundle exec rake release

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/NEXL-LTS/odata_duty-ruby.

If you're interested in extending the DSL to support new protocols or tool integrations, open an issue or start a discussion — the architecture is designed for extensibility.