jsmestad / jsonapi-consumer

Client framework for consuming JSONAPI services in Ruby
https://github.com/jsmestad/jsonapi-consumer
Apache License 2.0
94 stars 18 forks source link

Ideas #24

Closed pcriv closed 6 years ago

pcriv commented 6 years ago

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

jsmestad commented 6 years ago

@pablocrivella thanks for the write up. The idea of using dry makes some sense, the hurdle would be tying in the query builder and association handling.

I think the next logical step taking any of these ideas forward would be to eliminate inheritance / class variables and move to a mixin architecture. This put us in the best spot to allow threading support in future versions.

I'm pretty attached to Faraday for the foreseeable future, unless rest-client offers something Faraday cannot?

I would like to see how we can bring in dry-types / dry-struct into the project without breaking a bunch of existing features. LMK your thoughts.