sinatra-rest-api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 31a424922bc37c8f72631ef6c2991dd3cedb9da4
4
+ data.tar.gz: bf71346a4cc7b8ac29d1f3b898641d167efd9c52
5
+ SHA512:
6
+ metadata.gz: dab82f312bd1357305af0d0f7a3e46f495ab55cc682862a8377d33063def1e6fa8d019887429ed7118218173a98c7eeb6b08913def902845c51370e7befa95db
7
+ data.tar.gz: 948816326d8837873e9c30f36992da5c7dc6ee7595759c921cd5a617fac40991db871ebdad17de2eee6bfaf55b2d16107c41c8f1660cdf15f8be636c40b9c1ab
@@ -0,0 +1,22 @@
1
+ # sinatra-rest-api
2
+ $LOAD_PATH << File.join(File.dirname(__FILE__))
3
+
4
+ require 'sinatra/base'
5
+
6
+ require 'sinatra-rest-api/actions'
7
+ require 'sinatra-rest-api/adapter'
8
+ require 'sinatra-rest-api/provider'
9
+ require 'sinatra-rest-api/router'
10
+
11
+ # Sinatra namespace
12
+ module Sinatra
13
+ # RestApi module definition
14
+ module RestApi
15
+ def resource( klass, options = {}, &block )
16
+ Provider.new( klass, options, self, &block )
17
+ end
18
+ end
19
+
20
+ # helpers RestApi
21
+ register RestApi
22
+ end
@@ -0,0 +1,90 @@
1
+ module Sinatra
2
+ module RestApi
3
+ # Action
4
+ class Actions
5
+ DONE = { message: :ok }.freeze
6
+ # REF: https://en.wikipedia.org/wiki/Representational_state_transfer#Relationship_between_URL_and_HTTP_methods
7
+ SCHEMA = {
8
+ # Member actions
9
+ # list: { verb: :get, path: '/?:format?', fields: [ :id, :title, :author_id, :category_id ] },
10
+ read: { verb: :get, path: '/:id.?:format?' },
11
+ update: { verb: :put, path: '/:id.?:format?' },
12
+ delete: { verb: :delete, path: '/:id.?:format?' },
13
+ # Collection actions
14
+ list: { verb: :get, path: '/?.?:format?' },
15
+ replace: { verb: :put, path: '/?.?:format?' },
16
+ create: { verb: :post, path: '/?.?:format?' },
17
+ truncate: { verb: :delete, path: '/?.?:format?' },
18
+ # Member actions
19
+ # update: { verb: [ :post, :put ], path: '/:id.?:format?' },
20
+ }.freeze
21
+
22
+ def self.create( route_args, params, mapping )
23
+ unless route_args[:request].form_data?
24
+ route_args[:request].body.rewind
25
+ params.merge!( Provider.settings[:request_type].eql?( :json ) ? JSON.parse( route_args[:request].body.read ) : route_args[:request].body.read )
26
+ end
27
+ params[:_data] = {}
28
+ resource = route_args[:resource]
29
+ if !params[resource].nil? && params[resource].is_a?( Hash )
30
+ cols = mapping[:columns].call( nil ) + mapping[:relations].call( nil ).map { |rel| "#{rel}_attributes" } + mapping[:extra_fields].call( nil )
31
+ cols.delete( 'id' ) # TODO: option to set id field name
32
+ params[:_data] = params[resource].select { |key, _valye| cols.include? key }
33
+ # params[:_data] = params[resource]
34
+ params.delete( resource )
35
+ end
36
+ # params[:_data]['created_at'] = Time.now.strftime( '%F %T' ).to_s
37
+ # params[:_data]['updated_at'] = Time.now.strftime( '%F %T' ).to_s
38
+ result = mapping[:create].call( params )
39
+ # route_args[:response].headers['Location'] = '/' # TODO: todo
40
+ [ 201, result.to_json ]
41
+ end
42
+
43
+ def self.delete( _route_args, params, mapping )
44
+ mapping[:delete].call( params )
45
+ [ 200, DONE.to_json ]
46
+ end
47
+
48
+ def self.list( route_args, params, mapping )
49
+ # TODO: option to enable X-Total-Count ?
50
+ params[:_where] = params[:_where].nil? ? '1=1' : JSON.parse( params[:_where] )
51
+ # params[:_where] = '1=1' unless params[:_where].present?
52
+ route_args[:response].headers['X-Total-Count'] = mapping[:count].call( params ).to_s
53
+ result = mapping[:list].call( params, route_args[:fields] )
54
+ [ 200, result.to_json( include: mapping[:relations].call( nil ) ) ]
55
+ end
56
+
57
+ def self.other( _route_args, params, mapping )
58
+ action = params[:_action]
59
+ raise( APIError, 'Action not implemented' ) if mapping[action].nil?
60
+ ret = mapping[action].call( params )
61
+ [ 200, ret.nil? ? DONE.to_json : ret.to_json ]
62
+ end
63
+
64
+ def self.read( _route_args, params, mapping )
65
+ result = mapping[:read].call( params )
66
+ [ 200, result.to_json( include: mapping[:relations].call( nil ) ) ]
67
+ end
68
+
69
+ def self.update( route_args, params, mapping )
70
+ unless route_args[:request].form_data?
71
+ route_args[:request].body.rewind
72
+ params.merge!( Provider.settings[:request_type].eql?( :json ) ? JSON.parse( route_args[:request].body.read ) : route_args[:request].body.read )
73
+ end
74
+ params[:_data] = {}
75
+ resource = route_args[:resource]
76
+ if !params[resource].nil? && params[resource].is_a?( Hash )
77
+ # params[resource].delete( 'id' ) # TODO: option to set id field name
78
+ # params[:_data] = OpenStruct.new( params[resource] )
79
+ cols = mapping[:columns].call( nil ) + mapping[:relations].call( nil ).map { |rel| "#{rel}_attributes" } + mapping[:extra_fields].call( nil )
80
+ # TODO: consider relations _ids
81
+ cols.delete( 'id' ) # TODO: option to set id field name
82
+ params[:_data] = params[resource].select { |key, _valye| cols.include? key }
83
+ params.delete( resource )
84
+ end
85
+ mapping[:update].call( params )
86
+ [ 200, DONE.to_json ]
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,168 @@
1
+ require 'pry'
2
+
3
+ module Sinatra
4
+ module RestApi
5
+ # ORM Mapping
6
+ class Adapter
7
+ TYPES = %w(ActiveRecord Mongoid Sequel).freeze
8
+ NOT_FOUND = [ 'ActiveRecord::RecordNotFound', 'Mongoid::Errors::DocumentNotFound', 'Sequel::NoMatchingRow' ].freeze
9
+
10
+ attr_reader :mapping, :model_singular
11
+
12
+ def initialize( provider )
13
+ @provider = provider
14
+ @klass = @provider.klass
15
+ parents = @klass.ancestors.map( &:to_s ) # List of super classes
16
+ if parents.include? 'ActiveRecord::Base'
17
+ @type = 'ActiveRecord'
18
+ @mapping = setup_activerecord
19
+ elsif parents.include? 'Mongoid::Document'
20
+ @type = 'Mongoid'
21
+ @mapping = setup_mongoid
22
+ elsif parents.include? 'Sequel::Model'
23
+ @type = 'Sequel'
24
+ @mapping = setup_sequel
25
+ else
26
+ @type = nil
27
+ @mapping = {}
28
+ end
29
+ @model_singular = provider.klass.to_s.split( '::' ).last.gsub( /([A-Z]+)([A-Z][a-z])/, '\1_\2' ).gsub( /([a-z\d])([A-Z])/, '\1_\2' ).tr( '-', '_' ).downcase
30
+ end
31
+
32
+ protected
33
+
34
+ ## Sample setup method
35
+ # def setup
36
+ # {
37
+ # # Collection actions
38
+ # list: ->( _params ) { '' },
39
+ # create: ->( _params ) { { id: 0 } },
40
+ # truncate: ->( _params ) { '' },
41
+ # # Member actions
42
+ # read: ->( _params ) { '' },
43
+ # update: ->( _params ) { '' },
44
+ # delete: ->( _params ) { '' },
45
+ # # Other actions
46
+ # columns: ->( _params ) { [] },
47
+ # count: ->( _params ) { 0 },
48
+ # extra_fields: ->( _params ) { [] },
49
+ # relations: ->( _params ) { [] }
50
+ # }
51
+ # end
52
+
53
+ def setup_activerecord
54
+ {
55
+ # Collection actions
56
+ list: ->( params, fields ) { @klass.select( fields ).where( params[:_where] ).offset( params[:offset].to_i ).limit( params[:limit].nil? ? -1 : params[:limit].to_i ) },
57
+ # replace: { verb: :put, path: '/?' },
58
+ create: lambda do |params|
59
+ item = @klass.new( params[:_data] )
60
+ item.save!
61
+ { id: item.id }
62
+ end,
63
+ truncate: ->( _params ) { @klass.delete_all },
64
+ # Member actions
65
+ read: ->( params ) { @klass.find( params[:id] ) },
66
+ update: lambda do |params|
67
+ row = @klass.find( params[:id] ) # Same as read
68
+ row.update!( params[:_data] )
69
+ end,
70
+ delete: lambda do |params|
71
+ row = @klass.find( params[:id] ) # Same as read
72
+ row.destroy
73
+ end,
74
+ # Other actions
75
+ columns: ->( _params ) { @columns ||= @klass.column_names },
76
+ count: ->( params ) { @klass.where( params[:_where] ).count },
77
+ extra_fields: lambda do |_params|
78
+ @extra_fields ||= @klass.reflections.map do |_key, value|
79
+ Provider.klasses[value.class_name][:model_singular] + '_ids' if value.class.to_s == 'ActiveRecord::Reflection::HasManyReflection' || value.class.to_s == 'ActiveRecord::Reflection::HasAndBelongsToManyReflection'
80
+ end.compact
81
+ end,
82
+ relations: ->( _params ) { @relations ||= @klass.reflections.keys }
83
+ }
84
+ end
85
+
86
+ def setup_mongoid
87
+ {
88
+ # Collection actions
89
+ # list: ->( _params ) { @klass.all },
90
+ list: lambda do |params, fields|
91
+ if fields.nil?
92
+ @klass.all.skip( params[:offset].to_i ).limit( params[:limit].nil? ? 0 : params[:limit].to_i )
93
+ else
94
+ @klass.all.only( fields ).skip( params[:offset].to_i ).limit( params[:limit].nil? ? 0 : params[:limit].to_i )
95
+ end
96
+ end,
97
+ create: lambda do |params|
98
+ row = @klass.create!( params[:_data] )
99
+ { id: row.id }
100
+ end,
101
+ truncate: ->( _params ) { @klass.delete_all },
102
+ # Member actions
103
+ read: ->( params ) { @klass.find( params[:id] ) },
104
+ update: lambda do |params|
105
+ row = @klass.find( params[:id] ) # Same as read
106
+ row.update!( params[:_data] )
107
+ end,
108
+ delete: lambda do |params|
109
+ row = @klass.find( params[:id] ) # Same as read
110
+ row.destroy
111
+ end,
112
+ # Other actions
113
+ columns: ->( _params ) { @columns ||= @klass.attribute_names },
114
+ count: ->( _params ) { @klass.count },
115
+ extra_fields: ->( _params ) { [] },
116
+ relations: ->( _params ) { @relations ||= @klass.relations.keys }
117
+ # params: ->( _params ) { @params ||= @klass.attribute_names }, # TODO: try this way
118
+ }
119
+ end
120
+
121
+ def setup_sequel
122
+ {
123
+ # Collection actions
124
+ list: ->( params, fields ) { @klass.select( *fields ).where( params[:_where] ).offset( params[:offset].to_i ).limit( params[:limit].nil? ? nil : params[:limit].to_i ) },
125
+ # create: ->( params ) { @klass.insert( params[:_data] ) },
126
+ create: lambda do |params|
127
+ # Search nested ids
128
+ ids = {}
129
+ params[:_data].keys.reject { |k| !k.end_with?( '_ids' ) }.each do |k|
130
+ nk = k.sub( /_ids$/, '_pks' )
131
+ ids[nk] = params[:_data].delete k
132
+ end
133
+ # Create a new record
134
+ row = @klass.new( params[:_data] )
135
+ row.save
136
+ # Update ids
137
+ row.update( ids ) unless ids.empty?
138
+ { id: row.id }
139
+ end,
140
+ truncate: ->( _params ) { @klass.truncate },
141
+ # Member actions
142
+ read: ->( params ) { @klass.with_pk!( params[:id] ) },
143
+ update: lambda do |params|
144
+ row = @klass.with_pk!( params[:id] ) # Same as read
145
+ # Adjust nested ids
146
+ params[:_data].keys.reject { |k| !k.end_with?( '_ids' ) }.each do |k|
147
+ nk = k.sub( /_ids$/, '_pks' )
148
+ params[:_data][nk] = params[:_data].delete k
149
+ end
150
+ row.update( params[:_data] )
151
+ end,
152
+ delete: lambda do |params|
153
+ row = @klass.with_pk!( params[:id] ) # Same as read
154
+ row.delete
155
+ end,
156
+ # Other actions
157
+ columns: ->( _params ) { @columns ||= @klass.columns.map( &:to_s ) },
158
+ count: ->( params ) { @klass.where( params[:_where] ).count },
159
+ extra_fields: lambda do |_params|
160
+ @extra_fields ||= @klass.association_reflections.map { |_key, value| Provider.klasses[value[:class_name].split( '::' ).last][:model_singular] + '_ids' if value[:type] == :one_to_many || value[:type] == :many_to_many }.compact
161
+ end,
162
+ # extra_fields: ->( _params ) { [] },
163
+ relations: ->( _params ) { @relations ||= @klass.associations }
164
+ }
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,77 @@
1
+ module Sinatra
2
+ module RestApi
3
+ # Prodiver
4
+ class Provider
5
+ OPTIONS = [ :actions, :plural, :singular ].freeze
6
+ REQUEST = {
7
+ # content_types: [ :formdata, :json, :multipart, :www_form ]
8
+ content_types: [ :json, :www_form ]
9
+ }.freeze
10
+ RESPONSE = {}.freeze
11
+
12
+ attr_reader :adapter, :app, :klass, :options, :router
13
+
14
+ @@klasses = {}
15
+ @@settings = {
16
+ request_type: :www_form
17
+ }
18
+
19
+ def initialize( klass, opts, app, &block )
20
+ @klass = klass
21
+ @options = opts
22
+ @app = app
23
+ instance_eval( &block ) if block_given?
24
+ init_settings
25
+ @adapter = Adapter.new( self )
26
+ @router = Router.new( self )
27
+ @router.generate_routes
28
+ klass_name = klass.to_s.split( '::' ).last
29
+ @@klasses[klass_name] = { class: klass, model_singular: @adapter.model_singular }
30
+ decorate_model
31
+ end
32
+
33
+ def method_missing( name, *args, &block )
34
+ super unless OPTIONS.include? name
35
+ @options[name] = args[0]
36
+ end
37
+
38
+ def respond_to_missing?( name, include_all = false )
39
+ super unless OPTIONS.include? name
40
+ true
41
+ end
42
+
43
+ def self.klasses
44
+ @@klasses
45
+ end
46
+
47
+ def self.settings
48
+ @@settings
49
+ end
50
+
51
+ private
52
+
53
+ def decorate_model
54
+ # Attach meta data to model
55
+ @klass.class.module_eval { attr_accessor :restapi }
56
+ @klass.restapi = {}
57
+ @klass.restapi[:model_singular] = @adapter.model_singular
58
+ # @klass.restapi[:model_plural] = @router.plural
59
+ @klass.restapi[:path_singular] = @router.path_singular
60
+ @klass.restapi[:path_plural] = @router.plural
61
+ # @klass.restapi_routes = routes
62
+ # @klass.association_reflections[:chapters][:class_name].split( '::' ).last
63
+ # => Chapter
64
+ # @@klasses['Book'][:model_singular]
65
+ # => book
66
+ end
67
+
68
+ def init_settings
69
+ @@settings[:request_type] = @app.restapi_request_type if defined?( @app.restapi_request_type ) && REQUEST[:content_types].include?( @app.restapi_request_type )
70
+ end
71
+ end
72
+
73
+ # API Exception class
74
+ class APIError < StandardError
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,116 @@
1
+ module Sinatra
2
+ module RestApi
3
+ # Router
4
+ class Router
5
+ EXTS = [ 'json' ].freeze # TODO: load valid exts from an option
6
+ VERBS = [ :get, :post, :put, :patch, :delete, :options, :link, :unlink ].freeze
7
+
8
+ attr_reader :path_singular, :plural
9
+
10
+ @@all_routes = []
11
+
12
+ def initialize( provider )
13
+ @provider = provider
14
+ options = @provider.options
15
+ @path_singular = options[:singular].nil? ? provider.adapter.model_singular : options[:singular].downcase
16
+ @plural = options[:plural].nil? ? Router.pluralize( @path_singular ) : options[:plural].downcase
17
+ @routes = {}
18
+ routes = options[:actions].nil? ? Actions::SCHEMA.keys : options[:actions]
19
+ list = routes.delete :list
20
+ if routes.is_a?( Array )
21
+ # Move list action to the end to lower its priority
22
+ routes.push list unless list.nil?
23
+ routes.each { |route| init_route route, Actions::SCHEMA[route] if Actions::SCHEMA.include? route }
24
+ elsif routes.is_a?( Hash )
25
+ routes.each { |route, data| init_route route, data }
26
+ # Process list action in the end
27
+ init_route :list, list unless list.nil?
28
+ end
29
+ end
30
+
31
+ def generate_routes
32
+ @routes.each do |route, data|
33
+ path = "/#{@plural}#{data[:path]}"
34
+ if data[:verb].is_a?( Array )
35
+ data[:verb].each do |verb|
36
+ prepare_route( route: route, resource: @path_singular, verb: verb, path: path, fields: data[:fields], mapping: @provider.adapter.mapping )
37
+ end
38
+ else
39
+ prepare_route( route: route, resource: @path_singular, verb: data[:verb], path: path, fields: data[:fields], mapping: @provider.adapter.mapping )
40
+ end
41
+ end
42
+ @routes
43
+ end
44
+
45
+ def self.pluralize( string )
46
+ case string
47
+ when /(s|x|z|ch)$/
48
+ "#{string}es"
49
+ when /(a|e|i|o|u)y$/
50
+ "#{string}s"
51
+ when /y$/
52
+ "#{string[0..-2]}ies"
53
+ when /f$/
54
+ "#{string[0..-2]}ves"
55
+ when /fe$/
56
+ "#{string[0..-3]}ves"
57
+ else
58
+ "#{string}s"
59
+ end
60
+ end
61
+
62
+ def self.list_routes
63
+ @@all_routes
64
+ end
65
+
66
+ def self.on_error( e )
67
+ ret = 500
68
+ if e.class == APIError
69
+ ret = 400 # API error
70
+ elsif Adapter::NOT_FOUND.include? e.class.to_s
71
+ ret = 404 # Item not found
72
+ elsif Adapter::TYPES.include?( e.class.to_s.split( '::' )[0] ) || e.class == JSON::ParserError
73
+ ret = 400 # Invalid request
74
+ else
75
+ raise e
76
+ end
77
+ [ ret, { error: e.class.to_s, message: e.message }.to_json ]
78
+ end
79
+
80
+ private
81
+
82
+ def init_route( route, data )
83
+ return unless Actions::SCHEMA.include? route # Invalid action
84
+ @routes[route] = Actions::SCHEMA[route] unless data.is_a?( FalseClass )
85
+ return unless data.is_a?( Hash )
86
+ @routes[route][:verb] = data[:verb] unless data[:verb].nil?
87
+ @routes[route][:path] = data[:path] unless data[:path].nil?
88
+ end
89
+
90
+ def prepare_route( route_args )
91
+ if VERBS.include? route_args[:verb]
92
+ @@all_routes.push "#{route_args[:verb].upcase}: #{route_args[:path]}"
93
+ @provider.app.send( route_args[:verb], route_args[:path] ) do
94
+ # app = self
95
+ begin
96
+ raise( APIError, 'Invalid request format' ) if !params[:format].nil? && !EXTS.include?( params[:format] )
97
+ route_args[:request] = request
98
+ route_args[:response] = response
99
+ if Actions.respond_to? route_args[:route]
100
+ Actions.send( route_args[:route], route_args, params, route_args[:mapping] )
101
+ else
102
+ params[:_action] = route_args[:route]
103
+ Actions.other( route_args, params, route_args[:mapping] )
104
+ end
105
+ rescue StandardError => e
106
+ Router.on_error e
107
+ end
108
+ end
109
+ else
110
+ # logger.error "Invalid verb: #{verb}"
111
+ puts "Invalid verb: #{verb}" # TODO: use logger
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
metadata ADDED
@@ -0,0 +1,63 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sinatra-rest-api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mattia Roccoberton
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-11-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sinatra
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '='
18
+ - !ruby/object:Gem::Version
19
+ version: 1.4.7
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '='
25
+ - !ruby/object:Gem::Version
26
+ version: 1.4.7
27
+ description: 'Sinatra REST API generator: CRUD actions, nested resources, supports
28
+ ActiveRecord, Sequel and Mongoid'
29
+ email: mat@blocknot.es
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - lib/sinatra-rest-api.rb
35
+ - lib/sinatra-rest-api/actions.rb
36
+ - lib/sinatra-rest-api/adapter.rb
37
+ - lib/sinatra-rest-api/provider.rb
38
+ - lib/sinatra-rest-api/router.rb
39
+ homepage: https://github.com/blocknotes/sinatra-rest-api
40
+ licenses:
41
+ - ISC
42
+ metadata: {}
43
+ post_install_message:
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: 2.0.0
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ requirements: []
58
+ rubyforge_project:
59
+ rubygems_version: 2.4.8
60
+ signing_key:
61
+ specification_version: 4
62
+ summary: Sinatra REST API generator
63
+ test_files: []