Giter VIP home page Giter VIP logo

pragma's Introduction

Pragma

This project is not being maintained anymore.

Unfortunately, I'm not doing much work on REST APIs (or APIs in general) anymore, which means it's been a while since I last used Pragma in a real project or improved the ecosystem. If anyone wants to take over the project and keep it alive, feel free to reach out to me at [email protected].

Build Status Coverage Status Maintainability

Welcome to Pragma, an expressive, opinionated ecosystem for building beautiful RESTful APIs with Ruby.

You can think of this as a meta-gem that pulls in the following pieces:

Additionally, it also provides default CRUD operations that leverage all of the Pragma components and will make creating new resources in your API a breeze.

Looking for a Rails integration? Check out pragma-rails!

Philosophy

Pragma was created with a very specific goal in mind: to make the development of JSON APIs a matter of hours, not days. In other words, Pragma is for JSON APIs what Rails is for web applications.

Here are the ground rules:

  1. Pragma is opinionated. With Pragma, you don't get to make a lot of choices and that's exactly why people are using it: they want to focus on the business logic of their API rather than the useless details. We understand this approach will not work in some cases and that's alright. If you need more personalization, only use a subset of Pragma (see item 2) or something else.
  2. Pragma is modular. Pragma is built as a set of gems (currently 6), plus some standalone tools. You can pick one or more modules and use them in your application as you see fit. Even though they are completely independent from each other, they nicely integrate and work best when used together, creating an ecosystem that will dramatically speed up your design and development process.
  3. Pragma is designed to be Rails-free. Just as what happens with Trailblazer, our Rails integration is decoupled from the rest of the ecosystem and all of the gems can be used without Rails. This is just a byproduct of the project's design: Pragma is built with pure Ruby. pragma-rails is the only available framework integration at the moment, but more will come!

Why not Trailblazer?

Trailblazer and all of its companion projects are awesome. They are so awesome that Pragma is built on top of them: even though we're not using the Trailblazer gem itself yet, many of the Pragma gems are simply extensions of their Trailblazer counterparts:

Trailblazer and Pragma have different (but similar) places in the Ruby world: Trailblazer is an architecture for building all kinds of web applications in an intelligent, rational way, while Pragma is an architecture for building JSON APIs. We have shamelessly taken all of the flexibility and awesomeness from the Trailblazer project and restricted it to a narrow field of work, providing tools, helpers and integrations that could never be part of Trailblazer due to their specificity.

Thank you, guys!

Installation

Add this line to your application's Gemfile:

gem 'pragma'

And then execute:

$ bundle

Or install it yourself as:

$ gem install pragma

Usage

Project Structure

This gem works best if you follow the recommended structure for organizing resources:

└── api
    └── v1
        └── article
            ├── contract
            │   ├── create.rb
            │   └── update.rb
            ├── operation
            │   ├── create.rb
            │   ├── destroy.rb
            │   ├── index.rb
            │   └── update.rb
            └── decorator
            |   ├── collection.rb
            |   └── instance.rb
            └── policy.rb

Your modules and classes would, of course, follow the same structure: API::V1::Article::Policy and so on and so forth.

If you adhere to this structure, the gem will be able to locate all of your classes without any explicit configuration. This will save you a lot of time and is highly recommended.

Fantastic Five

Pragma comes with five built-in operations, often referred to as Fantastic Five (or "FF" for brevity). They are, of course, Index, Show, Create, Update and Destroy.

These operations leverage the full power of the integrated Pragma ecosystem and require all four components to be properly installed and configured in your application. You may reconfigure them to skip some of the steps, but it is highly recommended to use them as they come.

You can find these operations under lib/pragma/operation. To use them, simply create your own operations and inherit from ours. For instance:

module API
  module V1
    module Article
      module Operation
        class Create < Pragma::Operation::Create
          # This assumes that you have the following:
          #   1) an Article model
          #   2) a Policy (responding to #create?)
          #   3) a Create contract
          #   4) an Instance decorator
        end
      end
    end
  end
end

Macros

The FF are implemented through their own set of macros, which take care of stuff like authorizing, paginating, filtering etc.

If you want, you can use these macros in your own operations.

Classes

Used in: Index, Show, Create, Update, Destroy

The Classes macro is responsible of tying together all the Pragma components: put it into an operation and it will determine the class names of the related policy, model, decorators and contract. You can override any of these classes when defining the operation or at runtime if you wish.

Example usage:

module API
  module V1
    module Article
      module Operation
        class Create < Pragma::Operation::Base
          # Let the macro figure out class names.
          step Pragma::Macro::Classes()
          step :execute!
          
          # But override the contract.
          self['contract.default.class'] = Contract::CustomCreate
          
          def execute!(options)
            # `options` contains the following:
            #    
            #    `model.class`
            #    `policy.default.class`
            #    `policy.default.scope.class`
            #    `decorator.instance.class`
            #    `decorator.collection.class`
            #    `contract.default.class` 
            #    
            # These will be `nil` if the expected classes do not exist.
          end
        end
      end
    end
  end
end

Model

Used in: Index, Show, Create, Update, Destroy

The Model macro provides support for performing different operations with models. It can either build a new instance of the model, if you are creating a new record, for instance, or it can find an existing record by ID.

Example of building a new record:

module API
  module V1
    module Article
      module Operation
        class Create < Pragma::Operation::Base
          # This step can be done by Classes if you want.
          self['model.class'] = ::Article
           
          step Pragma::Macro::Model(:build)
          step :save!
          
          def save!(options)
            # Here you'd usually validate and assign parameters before saving.
  
            # ...
  
            options['model'].save!
          end
        end
      end
    end
  end
end

As we mentioned, Model can also be used to find a record by ID:

module API
  module V1
    module Article
      module Operation
        class Show < Pragma::Operation::Base
          # This step can be done by Classes if you want.
          self['model.class'] = ::Article
           
          step Pragma::Macro::Model(:find_by), fail_fast: true
          step :respond!
          
          def respond!(options)
            options['result.response'] = Response::Ok.new(
              entity: options['model']
            )
          end
        end
      end
    end
  end
end

In the example above, if the record is not found, the macro will respond with 404 Not Found and a descriptive error message for you. If you want to override the error handling logic, you can remove the fail_fast option and instead implement your own failure step.

Policy

Used in: Index, Show, Create, Update, Destroy

The Policy macro ensures that the current user can perform an operation on a given record.

Here's a usage example:

module API
  module V1
    module Article
      module Operation
        class Show < Pragma::Operation::Base
          # This step can be done by Classes if you want.
          self['policy.default.class'] = Policy
          
          step :model!
          step Pragma::Macro::Policy(), fail_fast: true
          # You can also specify a custom method to call on the policy:
          # step Pragma::Macro::Policy(action: :custom_method), fail_fast: true
          step :respond!
          
          def model!(params:, **)
            options['model'] = ::Article.find(params[:id])
          end
        end
      end
    end
  end
end

If the user is not authorized to perform the operation (i.e. if the policy's #show? method returns false), the macro will respond with 403 Forbidden and a descriptive error message. If you want to override the error handling logic, you can remove the fail_fast option and instead implement your own failure step.

The macro accepts the following options, which can be defined on the operation or at runtime:

  • policy.context: the context to use for the policy (optional, current_user is used if not provided).

Ordering

Used in: Index

As the name suggests, the Ordering macro allows you to easily implement default and user-defined ordering.

Here's an example:

module API
  module V1
    module Article
      module Operation
        class Index < Pragma::Operation::Base
          # This step can be done by Classes if you want.
          self['model.class'] = ::Article

          self['ordering.default_column'] = :published_at
          self['ordering.default_direction'] = :desc
          self['ordering.columns'] = %i[title published_at updated_at]

          step :model!

          # This will override `model` with the ordered relation.
          step Pragma::Macro::Ordering(), fail_fast: true

          step :respond!

          def model!(options)
            options['model'] = options['model.class'].all
          end
          
          def respond!(options)
            options['result.response'] = Response::Ok.new(
              entity: options['model']
            )
          end
        end
      end
    end
  end
end

If the user provides an invalid order column or direction, the macro will respond with 422 Unprocessable Entity and a descriptive error message. If you wish to implement your own error handling logic, you can remove the fail_fast option and implement your own failure step.

The macro accepts the following options, which can be defined on the operation or at runtime:

  • ordering.columns: an array of columns the user can order by.
  • ordering.default_column: the default column to order by (default: created_at).
  • ordering.default_direction: the default direction to order by (default: desc).
  • ordering.column_param: the name of the parameter which will contain the order column.
  • ordering.direction_param: the name of the parameter which will contain the order direction.

Pagination

Used in: Index

The Pagination macro is responsible for paginating collections of records through will_paginate. It also allows your users to set the number of records per page.

module API
  module V1
    module Article
      module Operation
        class Index < Pragma::Operation::Base
          # This step can be done by Classes if you want.
          self['model.class'] = ::Article

          step :model!

          # This will override `model` with the paginated relation.
          step Pragma::Macro::Pagination(), fail_fast: true

          step :respond!

          def model!(options)
            options['model'] = options['model.class'].all
          end
          
          def respond!(options)
            options['result.response'] = Response::Ok.new(
              entity: options['model']
            )
          end
        end
      end
    end
  end
end

In the example above, if the page or per-page number fail validation, the macro will respond with 422 Unprocessable Entity and a descriptive error message. If you wish to implement your own error handling logic, you can remove the fail_fast option and implement your own failure step.

The macro accepts the following options, which can be defined on the operation or at runtime:

  • pagination.page_param: the parameter that will contain the page number.
  • pagination.per_page_param: the parameter that will contain the number of items to include in each page.
  • pagination.default_per_page: the default number of items per page.
  • pagination.max_per_page: the max number of items per page.

This macro is best used in conjunction with the Collection and Pagination modules of Pragma::Decorator, which will expose all the pagination metadata.

Decorator

Used in: Index, Show, Create, Update

The Decorator macro uses one of your decorators to decorate the model. If you are using expansion, it will also make sure that the expansion parameter is valid.

Example usage:

module API
  module V1
    module Article
      module Operation
        class Show < Pragma::Operation::Base
          # This step can be done by Classes if you want.
          self['decorator.instance.class'] = Decorator::Instance
          
          step :model!
          step Pragma::Macro::Decorator(), fail_fast: true
          step :respond!
          
          def model!(params:, **)
            options['model'] = ::Article.find(params[:id])
          end
          
          def respond!(options)
            # Pragma does this for you in the default operations.
            options['result.response'] = Response::Ok.new(
              entity: options['result.decorator.instance']
            )
          end
        end
      end
    end
  end
end

The macro accepts the following options, which can be defined on the operation or at runtime:

  • expand.enabled: whether associations can be expanded.
  • expand.limit: how many associations can be expanded at once.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/pragmarb/pragma.

License

The gem is available as open source under the terms of the MIT License.

pragma's People

Contributors

aldesantis avatar ediogodias avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

pragma's Issues

Auto-include expanded associations

Pragma::Operation::Index.prepend (Module.new do
  def find_records
    relation = super
    if RolloutWrapper.active?(:auto_include_associations)
      relation = auto_include_associations(relation, params[:expand]) if params[:expand].present?
    end
    relation
  end

  private

  def auto_include_associations(relation, associations)
    to_include = associations.each_with_object({}) do |association, hash|
      hash = destruct_association(association, hash)
    end
    to_include = validate_associations(relation.model, to_include)
    Rails.logger.debug "Including: #{to_include}"
    relation.includes(to_include)
  end

  def destruct_association(association, hash = {})
    split = association.split('.')
    if split.length > 1
      value = split.shift
      hash[value] = {} unless hash[value]
      destruct_association(split.join('.'), hash[value])
    else
      value = split.shift
      hash[value] = {} unless hash[value]
    end
    hash
  end

  def validate_associations(model, associations)
    return associations if associations.empty?

    associations.each_with_object({}) do |(key, value), object|
      reflection = model.reflect_on_association(key.to_sym)
      if reflection.present?
        object[key] = validate_associations(reflection.klass, value)
      else
        {}
      end
    end
  end
end)

Courtesy of @ediogodias.

HATEOAS support

  • In Pragma::Decorator::Pagination operation, include links to the other pages.
  • Create a module for decorators to include a link to the resource itself.

Pragma::Auth

This would be a gem for authentication that provides an API endpoint to generate JWT tokens and a macro to authenticate users.

In addition to DRYing up authentication logic, it would allow us to decouple authentication from Rails by not requiring the definition of a current_user method.

Not sure if this should come in the form of a Rails engine or just operation classes that are then extended by the main app.

Model(): Support lookup by custom attributes

We should support the following formats:

step Model(invoice_id: :id) # WHERE invoice_id = params[:invoice_id] AND id = params[:id]
step Model(:id) # WHERE id = params[:id]
step Model(:slug, :id) # WHERE slug = params[:id] OR id = params[:id]

Policy(): Support overriding action name

It should be possible to do this:

class CustomCreate < Create
  step Pragma::Operation::Macro::Policy(action: :create?)
end

So that this is not required anymore:

class Policy < Pragma::Policy::Base
  def custom_create?
    create?
  end
end

Decorate errors automatically

Related to pragmarb/pragma-operation#2.

This pattern is very annoying:

options['result.response'] = Pragma::Operation::Response::NotFound
  .new
  .decorate_with(Pragma::Decorator::Error)

There is no reason for that decorate_with to exist, it's just boilerplate.

If result.response is a Pragma error response and it's not decorated, we should decorate it with Pragma::Decorator::Error automatically.

We either do it in pragma-operation or decorate the base operation here, e.g.

class Pragma::Operation::Base < Trailblazer::Operation
  def call(*)
    result = super
    result['result.response'] = '...'
  end
end

The problem with this is it cannot be overridden since it's not a step, and there is no way to ensure a step is actually run at the end of the operation.

Indicate where an operation has halted

It would be nice to have a way to see what was the last step executed by an operation, so that we can easily understand what is halting an operation.

We could set the last executed step on the operation's skill, e.g.

result = Api::V2::User::Operation::Show.call('id' => 1)
result['result.last_step'] # => "model.find_by"
result['result.failing_step'] # => "policy.default"

result.failing_step here can be computed like this (here be dragons):

steps = Api::V2::User::Operation::Show.skills['pipetree'].instance_variable_get('@index').keys
steps[steps.index(result['result.last_step']) + 1]

This should also be indicated in Pragma::Rails::NoResponseError.

Schema() macro for headless validation

https://github.com/pragmarb/pragma/wiki/Validating-query-parameters

If we implemented a Schema() macro in all operations that checks whether a schema.default skill is present and runs validations, query parameter validation could be simplified as follows:

module API
  module V1
    module Article
      module Operation
        class Index < Pragma::Operation::Index
          self['schema.default'] = Dry::Validation.Schema do
            optional(:user_id).maybe(:int?)
          end
        end
      end
    end
  end
end

This would also be very useful for headless operations, since they wouldn't require a model anymore.

Simplify responding

# Responds and returns true
respond_with :not_found, entity: {}, headers: {}, decorator: MyDecorator

# Responds and returns false
respond_with! :not_found, entity: {}, headers: {}, decorator: MyDecorator

Resource definition DSL

Rather than requiring users to write/generate boilerplate code for each resource, it shouldn't be too hard to allow users to define resources with a DSL and provide hooks for customization, e.g.

# app/resources/api/v1/post.rb
Pragma::Resource.define :post do |config| # entire block is optional
  config.model_class = ::Post # optional, computed by default

  config.attributes do |attributes|
    attributes.define :title, :body do
      type :string
      validate :required?
    end

    attributes.define :author do
      default ->(options) { options['current_user'] }
      validate :required?
      visible false # not exposed in decorator
    end

    attributes.define :send_newsletter do
      type :boolean
      only :create # cannot be updated
      virtual # not saved to model
    end
  end

  # These would accept any callable object which receives `options`.
  # We would also have before_* and around_* hooks.
  config.hooks do |hooks|
    hooks.after_create API::V1::Post::Hook::NotifySubscribers
    hooks.after_update API::V1::Post::Hook::TouchLastUpdate
    hooks.after_save API::V1::Post::Hook::GenerateSummary
    hooks.after_destroy API::V1::Post::Hook::RemoveFromFeed
  end

  config.policy do |policy|
    policy.on :create? do |user, post|
      # ...
    end
  end
end

We should still support custom resources defined the old way.

This would probably be a separate library/gem (Pragma::Resource?) providing a catch-all API endpoint that looks at the configuration and executes the appropriate logic.

Automatic filtering

class Index < Pragma::Operation::Index
  self['filters'] = [
    Pragma::Operation::Filter::Like.new(param: :by_name, column: :name),
    Pragma::Operation::Filter::Ilike.new(param: :by_iname, column: :name),
    Pragma::Operation::Filter::Eq.new(param: :by_country_code, column: :country_code),
  ]
end

Classes() does not support nested operations

It shouldn't be required to explicitly specify classes when the operation is nested, e.g.

class Create < Pragma::Operation::Create
  class CustomCreate < Create
    step Pragma::Operation::Macro::Classes()
  end
end

Currently, this will fail to locate all the classes, because it looks in the wrong namespace.

Interestingly enough, this works and is just plain Ruby:

self['model.class'] = '???' # not sure how to do this one
self['policy.default.class'] = Policy
self['policy.default.scope.class'] = Policy::Scope
self['decorator.instance.class'] = Decorator::Instance
self['decorator.collection.class'] = Decorator::Collection
self['contract.default.class'] = Contract::Create::CustomCreate

This can be done by computing namespace via string matching rather than indexing, e.g.

operation_klass = 'API::V1::Post::Operation::Create::CustomCreate'
contract_klass = operation_klass.

Using with `pragma-devise`, error `uninitialized constant API::V1::Token::Contract`

Using pragma 1.2.4 with pragma-devise kept returning an uninitialized constant API::V1::Token::Contract error.

This seems to be stemming from const_get at line 73 of pragma-1.2.4/lib/pragma/operation/defaults.rb

        def class_exists?(klass)
          begin
            Object.const_get(klass)
          rescue NameError => e
            raise e unless e.message.include?("uninitialized constant #{klass}")
          end

          Object.const_defined?(klass)
        end

Downgrading to 1.2.1 fixed the issue (temporarily)

Tracking updated attributes

class Update < Pragma::Operation::Update
  step :do_something!

  def do_something!(options)
    # { "my_attr" => ["before_value", "after_value"] }
    return unless options['result.updated_attributes'].key?('my_attr')

    # do something...
  end
end

Support IdentityCache

We should support caching, but it should be opt-in, e.g.

class Show < Pragma::Operation::Show
  self['model.caching'] = true
end

This should use #fetch instead of #find_by in Model (and fail when #fetch is not defined). It should also use #fetch_[assoc] for associations in the decorator adapter (when defined).

Perhaps it should be its own gem, e.g. Pragma::Cache.

Automatic ordering

class Index < Pragma::Operation::Index
  self['ordering.columns'] = %i[created_at title]
  self['ordering.default_column'] = :created_at
  self['ordering.default_direction'] = :desc
end

Also, order by created_at DESC by default, if the model responds to created_at.

Pragma::Metadata

This would be a gem to associate metadata with any API resources, like Stripe does in their API.

Usage should be pretty simple.

# app/models/post.rb
class Post < ApplicationRecord
  include Pragma::Metadata::Model
  # ...
end

# app/resources/api/v1/post/contract/base.rb
class API::V1::Post::Contract::Base < Pragma::Contract::Base
  include Pragma::Metadata::Contract
  # ...
end

# app/resources/api/v1/post/decorator/instance.rb
class API::V1::Post::Decorator::Instance < Pragma::Decorator::Base
  include Pragma::Metadata::Decorator
  # ...
end

Normalize pipetrees

Right now, a lot of the pipetrees are using a mix of symbols and strings - also, some steps have an exclamation mark at the end, while others do not.

We should override the name of all steps to use strings without an exclamation mark.

Extract ORM-specific logic in pragma-orm

We're starting to have a lot of ORM-dependent logic (association loading, association inclusion, record finding and pagination). Right now this logic is spread across pragma, pragma-contract and pragma-decorator, which means adding support for a new ORM requires opening PRs in three separate gems (possibly more in the future as we add more features).

Perhaps it would be a good idea to extract all ORM-dependent logic into a central pragma-orm gem that is then required in the public gems. I envision the following modules:

The downside is that the gems would bundle all ORM support features even when not all of them are used, but this seems like an acceptable tradeoff.

Another problem could be that currently the ORM support modules are tightly coupled to the gems they're part of. The most notable example of this is Pragma::Decorator::Association which performs a lot of checks on the consistency between user data and the real association. This could be solved with some sort of abstraction but it needs to be designed properly.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.