Coder Social home page Coder Social logo

jsmestad / jsonapi-consumer Goto Github PK

View Code? Open in Web Editor NEW
94.0 5.0 18.0 170 KB

Client framework for consuming JSONAPI services in Ruby

Home Page: https://github.com/jsmestad/jsonapi-consumer

License: Apache License 2.0

Ruby 99.93% Shell 0.07%
json-api ruby jsonapi-standards activemodel

jsonapi-consumer's Introduction

JSONAPI::Consumer

An ActiveModel-compliant consumer framework for communicating with JSONAPI-based APIs.

CircleCI

Installation

Add this line to your application's Gemfile:

gem 'jsonapi-consumer', '~> 1.0'

And then execute:

$ bundle

Usage

It's suggested to create a base resource for the whole API that you can re-use.

class Base < JSONAPI::Consumer::Resource
  # self.connection_options = {} # Faraday connection options
  # self.json_key_format = :dasherized_key # (default: underscored_key)
  # self.route_format = :dasherized_route # (default: underscored_route)
  self.site = 'http://localhost:3000/api/'
end

Then inherit from that Base class for each resource defined in your API.

module Blog
  class Author < Base
    has_many :posts, class_name: 'Blog::Post'
  end

  class Post < Base
    has_one :user, class_name: 'Blog::User'
    has_many :comments, class_name: 'Blog::Comment'
  end

  class User < Base

  end

  class Comment < Base

  end
end

Contributing

  1. Fork it ( https://github.com/jsmestad/jsonapi-consumer/fork )
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Copyright & License

JSONAPI::Consumer is distributed under the Apache 2.0 License. See LICENSE.txt file for more information.

Version v1 is a rewrite is based on the excellent work by json_api_client v1.5.3.

jsonapi-consumer's People

Contributors

anveo avatar jsmestad avatar mattmueller avatar simmerz 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  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

jsonapi-consumer's Issues

Support side-loading for associations

There is no support for JSONAPI payloads that do not included an embedded object (even if its "side-loaded" in the top-level links or linked object)

undefined method `parser' for Parser:Module after bumping parser dependency to 2.6

Describe the bug
After upgrading parser dependency from 2.5.1.2 to 2.6.0.0 (because of Rubocop upgrade), I started to see the following errors from my test suite:

  Failure/Error: klass.parser.parse(klass, connection.run(type, path, params, headers))

     NoMethodError:
       undefined method `parser' for Parser:Module
       Did you mean?  parent
     # /Users/michal/.rvm/gems/ruby-2.5.3/bundler/gems/jsonapi-consumer-7016fe3eb5ee/lib/jsonapi/consumer/included_data.rb:11:in `block (2 levels) in initialize'
     # /Users/michal/.rvm/gems/ruby-2.5.3/bundler/gems/jsonapi-consumer-7016fe3eb5ee/lib/jsonapi/consumer/included_data.rb:10:in `map'
     # /Users/michal/.rvm/gems/ruby-2.5.3/bundler/gems/jsonapi-consumer-7016fe3eb5ee/lib/jsonapi/consumer/included_data.rb:10:in `block in initialize'
     # /Users/michal/.rvm/gems/ruby-2.5.3/bundler/gems/jsonapi-consumer-7016fe3eb5ee/lib/jsonapi/consumer/included_data.rb:8:in `each'
     # /Users/michal/.rvm/gems/ruby-2.5.3/bundler/gems/jsonapi-consumer-7016fe3eb5ee/lib/jsonapi/consumer/included_data.rb:8:in `inject'
     # /Users/michal/.rvm/gems/ruby-2.5.3/bundler/gems/jsonapi-consumer-7016fe3eb5ee/lib/jsonapi/consumer/included_data.rb:8:in `initialize'
     # /Users/michal/.rvm/gems/ruby-2.5.3/bundler/gems/jsonapi-consumer-7016fe3eb5ee/lib/jsonapi/consumer/parsers/parser.rb:108:in `new'
     # /Users/michal/.rvm/gems/ruby-2.5.3/bundler/gems/jsonapi-consumer-7016fe3eb5ee/lib/jsonapi/consumer/parsers/parser.rb:108:in `handle_included'
     # /Users/michal/.rvm/gems/ruby-2.5.3/bundler/gems/jsonapi-consumer-7016fe3eb5ee/lib/jsonapi/consumer/parsers/parser.rb:18:in `block in parse'
     # /Users/michal/.rvm/gems/ruby-2.5.3/bundler/gems/jsonapi-consumer-7016fe3eb5ee/lib/jsonapi/consumer/parsers/parser.rb:8:in `tap'
     # /Users/michal/.rvm/gems/ruby-2.5.3/bundler/gems/jsonapi-consumer-7016fe3eb5ee/lib/jsonapi/consumer/parsers/parser.rb:8:in `parse'
     # ./app/resources/custom_requestor.rb:22:in `request'
     # /Users/michal/.rvm/gems/ruby-2.5.3/bundler/gems/jsonapi-consumer-7016fe3eb5ee/lib/jsonapi/consumer/query/requestor.rb:28:in `get'
     # /Users/michal/.rvm/gems/ruby-2.5.3/bundler/gems/jsonapi-consumer-7016fe3eb5ee/lib/jsonapi/consumer/query/builder.rb:102:in `find'
     # ./app/controllers/stores_controller.rb:192:in `set_store'

To Reproduce
Steps to reproduce the behavior:

  1. Upgrade parser dependency
  2. Run any code which uses included_data.rb

Expected behavior
No error should occur.

Screenshots
N/A

Desktop (please complete the following information):
N/A

Smartphone (please complete the following information):
N/A

Additional context

My guess is that JSON-API Consumer is incompatible with the new version of parser gem, which came out yesterday.

How does one build has_many associations in rails forms using nested fields?

I'm struggling to figure out how to build has_many associations in rails using nested fields.

I've got has_many :addresses on an Order resource. If I create a new resource, I'd expect order.addresses to be something other than nil, and if I try setting it to [Address.new], it still returns nil.

Am I missing something?

Ideas

On my implementation of a client i used dry-types and dry-struct to define the types on the jsonapi spec.

For example: (copied all the types for the sake of sharing one snippet ๐Ÿ˜…)

# frozen_string_literal: true

module JSONAPI
  module Types
    include Dry::Types.module

    Data = Hash | Array
    
    Link = String | Constructor(JSONAPI::Types::LinkObject)
    
    Links = Map(key_type: Symbol, value_type: JSONAPI::Types::Link)
    
    ErrorsArray = Array.of(Constructor(JSONAPI::Types::ErrorObject)).default([])
    
    Relationships = Map(key_type: Symbol, value_type: Constructor(JSONAPI::Types::Relationship))
    
    ResourcesArray = Array.of(Constructor(JSONAPI::Types::Resource)).default([])
    
    PrimaryData = Constructor(JSONAPI::Types::Data) do |data|
      case data
      when Hash
        JSONAPI::Types::Resource.call(data)
      when Array
        JSONAPI::Types::ResourcesArray.call(data)
      end
    end

    class Base < Dry::Struct
      transform_types { |type| type.meta(omittable: true) }
    end

    class Document < Base
      attribute :data, JSONAPI::Types::PrimaryData
      attribute :errors, JSONAPI::Types::ErrorsArray
      attribute :links, JSONAPI::Types::Links
      attribute :included, JSONAPI::Types::ResourcesArray
      attribute :meta, Types::Hash

      def errors?
        errors.any?
      end

      class << self
        def parse(payload)
          new transform_keys(payload.is_a?(String) ? parse_json(payload) : payload)
        end

        def parse_json(payload)
          JSON.parse(payload, symbolize_names: true)
        rescue JSON::ParserError
          {}
        end

        private

        def transform_keys(payload)
          (payload || {}).deep_transform_keys do |key|
            { attributes: :resource_attributes }.fetch(key.to_sym, key.to_sym)
          end
        end
      end
    end

    class Resource < Base
      attribute :id, Types::String
      attribute :type, Types::String
      # Renamed to avoid collision with Dry::Struct
      attribute :resource_attributes, Types::Hash
      attribute :relationships, JSONAPI::Types::Relationships
      attribute :links, JSONAPI::Types::Links

      def identifier_object
        attributes.slice(:id, :type)
      end
    end

    class Relationship < Base
      attribute :data, JSONAPI::Types::Data
      attribute :links, JSONAPI::Types::Links
      attribute :meta, Types::Hash

      def resource_identifier_objects
        case data
        when Hash
          [data]
        when Array
          data
        else
          []
        end
      end
    end

    class LinkObject < Base
      attribute :href, Types::String
      attribute :meta, Types::Hash
    end

    class ErrorSource < Base
      attribute :pointer, Types::String
      attribute :parameter, Types::String
    end

    class ErrorObject < Base
      attribute :id, Types::String
      attribute :status, Types::String
      attribute :code, Types::String
      attribute :title, Types::String
      attribute :detail, Types::String
      attribute :meta, Types::Hash
      attribute :links, JSONAPI::Types::Links
      attribute :source, Types::Constructor(JSONAPI::Types::ErrorSource)
    end
  end
end

Then you could just parse the payload like:

JSONAPI::Types::Document.parse(payload)

What i like of this approach is that the types definition of the spec could be extracted to a gem in other to be used on different projects.

I also went for using rest-client instead of Faraday.
Because i wanted to have a client class that was usable on a more higher lever without known about resources:

# frozen_string_literal: true

module JSONAPI
  module Client
    RestClient.log = Logger.new(STDERR) if Rails.env.development?

    def self.default_headers
      {
        accept: "application/vnd.api+json",
        content_type: "application/vnd.api+json"
      }
    end

    def self.headers(additional_headers)
      default_headers.merge(additional_headers)
    end

    def self.create(url, payload = {}, additional_headers = {})
      execute :post, url, payload: payload.to_json, headers: headers(additional_headers)
    end

    def self.update(url, payload = {}, additional_headers = {})
      execute :patch, url, payload: payload.to_json, headers: headers(additional_headers)
    end

    def self.delete(url, additional_headers = {})
      execute :delete, url, headers: headers(additional_headers)
    end

    def self.fetch(url, query = {}, additional_headers = {})
      execute :get, url, headers: headers(additional_headers).merge(params: query)
    end

    def self.execute(method, url, options = {})
      response =
        begin
          RestClient::Request.execute(options.merge(method: method, url: url))
        rescue RestClient::UnprocessableEntity => error
          error.response
        end

      JSONAPI::Types::Document.parse(response)
    end
  end
end

On top of this i started implementing the Resource functionality ActiveResource style:

# frozen_string_literal: true

module JSONAPI
  module Resource
    class Client
      attr_reader :base_uri, :resource_path, :headers

      def initialize(base_uri, resource_path, headers = {})
        @base_uri = base_uri
        @resource_path = resource_path
        @headers = headers
      end

      def collection_url
        URI.join(base_uri, resource_path).to_s
      end

      def individual_url(id)
        [collection_url, id].join("/")
      end

      def related_url(id, relationship)
        [collection_url, id, relationship].join("/")
      end

      def find(id, query = {})
        JSONAPI::Client.fetch(individual_url(id), query, headers)
      end

      def all(query = {})
        JSONAPI::Client.fetch(collection_url, query, headers)
      end

      def related(id, relationship, query = {})
        JSONAPI::Client.fetch(related_url(id, relationship), query, headers)
      end

      def create(payload)
        JSONAPI::Client.create(collection_url, payload, headers)
      end

      def update(id, payload)
        JSONAPI::Client.update(individual_url(id), payload, headers)
      end

      def destroy(id)
        JSONAPI::Client.delete(individual_url(id), headers)
      end
    end
  end
end
# frozen_string_literal: true

module JSONAPI
  module Resource
    class Base # rubocop:disable Metrics/ClassLength
      class_attribute :base_uri

      attr_accessor :id, :persisted, :resource_attributes, :relationships, :errors, :links, :included

      delegate :client, :type, to: :class

      alias persisted? persisted

      class << self
        def table_name
          resource_name.pluralize
        end

        def type
          table_name
        end

        def resource_name
          name.demodulize.underscore
        end

        def resource_path
          table_name
        end

        def client
          JSONAPI::Resource::Client.new(base_uri, resource_path, default_headers)
        end

        def default_headers
          {}
        end

        def persist(params = {})
          new(params).tap(&:persist)
        end

        def attribute(name, type:)
          define_method(name) do
            type.call(resource_attributes[name])
          end

          define_method("#{name}=") do |value|
            resource_attributes[name] = type.call(value)
          end
        end

        def create(params = {})
          new(params).tap(&:save)
        end

        def create!(params = {})
          new(params).tap(&:save!)
        end

        def find(id, query = {})
          document = client.find(id, query)

          if document.errors?
            new(errors: document.errors)
          else
            persist(**document.data.to_hash, included: document.included)
          end
        end

        def all(query = {})
          document = client.all(query)

          if document.errors?
            JSONAPI::Resource::Collection.new(errors: document.errors)
          else
            JSONAPI::Resource::Collection.new(links: document.links, resources: map_to_resources(document))
          end
        end

        def map_to_resources(document)
          document.data.map do |resource|
            persist(**resource.to_hash, included: document.included)
          end
        end
      end

      def initialize(params = {})
        @id = params[:id]
        @resource_attributes = params.fetch(:resource_attributes, {})
        @relationships = params.fetch(:relationships, {})
        @included = params.fetch(:included, [])
        @errors = params.fetch(:errors, [])
        @links = params.fetch(:links, {})
        @persisted = false
      end

      def method_missing(method_name, *args, &block)
        return resource_attributes.fetch(method_name) if respond_to_missing?(method_name)
        super
      end

      def respond_to_missing?(method_name, *)
        resource_attributes.key?(method_name)
      end

      def save
        document = persisted? ? client.update(id, request_payload) : client.create(request_payload)

        if document.errors?
          @errors = document.errors
        else
          @errors = []
          @id = document.data.id
          @resource_attributes = document.data.resource_attributes || {}
          @relationships = document.data.relationships || {}
          @included = document.included
          @links = document.data.links || {}
          @persisted = true
        end

        @errors.empty?
      end

      def save!
        raise JSONAPI::UnprocessableEntity unless save
      end

      def destroy
        document = client.destroy(id)

        if document.errors?
          @errors = document.errors
          @persisted = true
        else
          @persisted = false
        end

        !persisted
      end

      def destroy!
        raise JSONAPI::UnprocessableEntity unless destroy
      end

      def persist
        @persisted = true
      end

      def request_payload
        JSONAPI::Payload.new(type: type)
          .with_id(id)
          .with_attributes(resource_attributes)
          .with_relationships(relationships)
          .to_h
      end

      def to_relationship
        JSONAPI::Types::Relationship.new(data: identifier_object)
      end

      def identifier_object
        { type: type, id: id }
      end

      def fetch_related_collection(relationship, klass, query = {})
        document = client.related(id, relationship, query)

        if document.errors?
          JSONAPI::Resource::Collection.new(errors: document.errors)
        else
          JSONAPI::Resource::Collection.new(links: document.links, resources: klass.map_to_resources(document))
        end
      end
    end
  end
end

Of course my client is far from being as feature rich as this gem, but i needed a bit more of control, so i went for it. ๐Ÿ˜…
I wanted to share it to see if there is some ideas you think are worth including on this gem.
I totally understand if you don't agree on the decisions i took.
Just trying to see if we could collaborate on just one library :)

Resources

JSON API RC3 tracking

Hey there! I just wanted to let you know that JSON API has hit RC3, and we'd like client libraries to start implementing it like it was 1.0, as a final check that we're good for 1.0. I wanted to make this issue to let you know!

json-api/json-api#484

Cannot set `host` value on a parent class

It appears something like this is not supported yet:

class Base
  include JSONAPI::Consumer::Resource
  self.host = 'http://foobar/api/'
end

class User < Base

end

It errors with NotImplementedError: host was not set

Requests not formatted properly

We're using JSONAPI::Resource to serve an API and are attempting to consume this API with jsonapi-consumer. I can curl the API just fine and everything works as expected. But when I use your gem, the request is not formatted correctly. See below:

class Brand
  include JSONAPI::Consumer::Resource
  self.host = "http://localhost:4000/api/v2/"
end
> Brand.find 1
JSONAPI::Consumer::Errors::BadFormat: 406 GET http://localhost:4000/api/v2/brands/1 {"errors":[{"title":"Not acceptable","detail":"All requests must use the 'application/vnd.api+json' Accept without media type parameters. This request specified 'application/json'.","code":"406","status":"406"}]}
from /Users/tonybeninate/.rvm/gems/ruby-2.4.1@audiogon/gems/jsonapi-consumer-0.1.1/lib/jsonapi/consumer/middleware/raise_error.rb:6:in `on_complete'

I don't see any mention of this in the docs and it seems that this should be the default configuration out of the gate using your gem. Am I missing something here? Thanks.

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.