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:
- Set up the basic OData API structure:
bin/rails generate odata_duty:install
- 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 (
$filtersupports implicitandand flat single-operatoror, e.g.color eq 'red' or color eq 'blue', via theod_filter_orhook; mixingand/oror 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: )
end
def create
render json: schema.create(params[:url], context: self, query_options: )
end
private
def
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
- OData Crash Course
- MCP Crash Course
- Using MCP
- Using
$select - Using
$search - Using
create,update, anddelete - Using
computed(read-only) properties - Using
$oas2(OpenAPI/Swagger) with Power Automate
Documentation convention: Every externally-facing feature (a new DSL option, query option, or protocol surface) ships with a
doc/using_*.mdguide 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.