Skip to content

NEXL-LTS/odata_duty-ruby

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

38 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

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 works with reporting tools, automation tools, and LLMs (WIP) 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.


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 metadata
  render xml: OdataDuty::EdmxSchema.metadata_xml(schema)
end

def oas2
  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
# 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.


About

Easily expose your ruby application as an odata api

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors