sinatra-rest-api 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []