fmrest 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,53 @@
1
+ module FmRest
2
+ module Spyke
3
+ module Model
4
+ module Connection
5
+ extend ::ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :fmrest_config, instance_accessor: false, instance_predicate: false
9
+
10
+ class_attribute :faraday_block, instance_accessor: false, instance_predicate: false
11
+ class << self; private :faraday_block, :faraday_block=; end
12
+
13
+ # FM Data API expects PATCH for updates (Spyke's default was PUT)
14
+ self.callback_methods = { create: :post, update: :patch }.freeze
15
+ end
16
+
17
+ class_methods do
18
+ def connection
19
+ super || fmrest_connection
20
+ end
21
+
22
+ # Sets a block for injecting custom middleware into the Faraday
23
+ # connection. Example usage:
24
+ #
25
+ # class MyModel < FmRest::Spyke::Base
26
+ # faraday do |conn|
27
+ # # Set up a custom logger for the model
28
+ # conn.response :logger, MyApp.logger, bodies: true
29
+ # end
30
+ # end
31
+ #
32
+ def faraday(&block)
33
+ self.faraday_block = block
34
+ end
35
+
36
+ private
37
+
38
+ def fmrest_connection
39
+ @fmrest_connection ||= FmRest::V1.build_connection(fmrest_config) do |conn|
40
+ faraday_block.call(conn) if faraday_block
41
+
42
+ # Pass the class to JsonParser's initializer so it can have
43
+ # access to extra context defined in the model, e.g. a portal
44
+ # where name of the portal and the attributes prefix don't match
45
+ # and need to be specified as options to `portal`
46
+ conn.use FmRest::Spyke::JsonParser, self
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,81 @@
1
+ require "fmrest/spyke/relation"
2
+
3
+ module FmRest
4
+ module Spyke
5
+ module Model
6
+ module Orm
7
+ extend ::ActiveSupport::Concern
8
+
9
+ included do
10
+ # Allow overriding FM's default limit (by default it's 100)
11
+ class_attribute :default_limit, instance_accessor: false, instance_predicate: false
12
+
13
+ class_attribute :default_sort, instance_accessor: false, instance_predicate: false
14
+ end
15
+
16
+ class_methods do
17
+ delegate :limit, :offset, :sort, :query, :portal, to: :all
18
+
19
+ def all
20
+ # Use FmRest's Relation insdead of Spyke's vanilla one
21
+ current_scope || Relation.new(self, uri: uri)
22
+ end
23
+
24
+ # Extended fetch to allow properly setting limit, offset and other
25
+ # options, as well as using the appropriate HTTP method/URL depending
26
+ # on whether there's a query present in the current scope, e.g.:
27
+ #
28
+ # Person.query(first_name: "Stefan").fetch # POST .../_find
29
+ #
30
+ def fetch
31
+ if current_scope.has_query?
32
+ scope = extend_scope_with_fm_params(current_scope, prefixed: false)
33
+ scope = scope.where(query: scope.query_params)
34
+ scope = scope.with(FmRest::V1::find_path(layout))
35
+ else
36
+ scope = extend_scope_with_fm_params(current_scope, prefixed: true)
37
+ end
38
+
39
+ previous, self.current_scope = current_scope, scope
40
+ current_scope.has_query? ? scoped_request(:post) : super
41
+ ensure
42
+ self.current_scope = previous
43
+ end
44
+
45
+ private
46
+
47
+ def extend_scope_with_fm_params(scope, prefixed: false)
48
+ prefix = prefixed ? "_" : nil
49
+
50
+ where_options = {}
51
+
52
+ where_options["#{prefix}limit"] = scope.limit_value if scope.limit_value
53
+ where_options["#{prefix}offset"] = scope.offset_value if scope.offset_value
54
+
55
+ if scope.sort_params.present? && scope.limit_value != 1
56
+ where_options["#{prefix}sort"] =
57
+ prefixed ? scope.sort_params.to_json : scope.sort_params
58
+ end
59
+
60
+ if scope.portal_params.present?
61
+ where_options["portal"] =
62
+ prefixed ? scope.portal_params.to_json : scope.portal_params
63
+ end
64
+
65
+ scope.where(where_options)
66
+ end
67
+ end
68
+
69
+ # Ensure save returns true/false, following ActiveRecord's convention
70
+ #
71
+ def save(options = {})
72
+ if options[:validate] == false || valid?
73
+ super().present? # Failed save returns empty hash
74
+ else
75
+ false
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,28 @@
1
+ module FmRest
2
+ module Spyke
3
+ module Model
4
+ module Uri
5
+ extend ::ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ # Accessor for FM layout (helps with building the URI)
9
+ #
10
+ def layout(layout = nil)
11
+ @layout = layout if layout
12
+ @layout ||= model_name.name
13
+ end
14
+
15
+ # Extend uri acccessor to default to FM Data schema
16
+ #
17
+ def uri(uri_template = nil)
18
+ if @uri.nil? && uri_template.nil?
19
+ return FmRest::V1.record_path(layout) + "(/:id)"
20
+ end
21
+
22
+ super
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,53 @@
1
+ module FmRest
2
+ module Spyke
3
+ # Extend Spyke's HasMany association with custom options
4
+ #
5
+ class Portal < ::Spyke::Associations::HasMany
6
+ def initialize(*args)
7
+ super
8
+
9
+ # Portals are always embedded, so no special URI
10
+ @options[:uri] = ""
11
+ end
12
+
13
+ def portal_key
14
+ return @options[:portal_key] if @options[:portal_key]
15
+ name
16
+ end
17
+
18
+ def attribute_prefix
19
+ @options[:attribute_prefix] || portal_key
20
+ end
21
+
22
+ def parent_changes_applied
23
+ each do |record|
24
+ record.changes_applied
25
+ # Saving portal data doesn't provide new modIds for the
26
+ # portal records, so we clear them instead. We can still save
27
+ # portal data without a mod_id (it's optional in FM Data API)
28
+ record.mod_id = nil
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ # Spyke::Associations::HasMany#initialize calls primary_key to build the
35
+ # default URI, which causes a NameError, so this is here just to prevent
36
+ # that. We don't care what it returns as we override the URI with nil
37
+ # anyway
38
+ def primary_key; end
39
+
40
+ # Make sure the association doesn't try to fetch records through URI
41
+ def uri; nil; end
42
+
43
+ def embedded_data
44
+ parent.attributes[portal_key]
45
+ end
46
+
47
+ def add_to_parent(record)
48
+ find_some << record
49
+ record
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,140 @@
1
+ module FmRest
2
+ module Spyke
3
+ class Relation < ::Spyke::Relation
4
+ SORT_PARAM_MATCHER = /(.*?)(!|__desc(?:end)?)?\Z/.freeze
5
+
6
+ # We need to keep these separate from regular params because FM Data API
7
+ # uses either "limit" or "_limit" (or "_offset", etc.) as param keys
8
+ # depending on the type of request, so we can't set the params until the
9
+ # last moment
10
+ attr_accessor :limit_value, :offset_value, :sort_params, :query_params,
11
+ :portal_params
12
+
13
+ def initialize(*_args)
14
+ super
15
+
16
+ @limit_value = klass.default_limit
17
+
18
+ if klass.default_sort.present?
19
+ @sort_params = Array.wrap(klass.default_sort).map { |s| normalize_sort_param(s) }
20
+ end
21
+
22
+ @query_params = []
23
+ @portal_params = []
24
+ end
25
+
26
+ def limit(value)
27
+ with_clone { |r| r.limit_value = value }
28
+ end
29
+
30
+ def offset(value)
31
+ with_clone { |r| r.offset_value = value }
32
+ end
33
+
34
+ # Allows sort params given in either hash format (using FM Data API's
35
+ # format), or as a symbol, in which case the of the attribute must match
36
+ # a known mapped attribute, optionally suffixed with ! or __desc[end] to
37
+ # signify it should use descending order.
38
+ #
39
+ # E.g.
40
+ #
41
+ # Person.sort(:first_name, :age!)
42
+ # Person.sort(:first_name, :age__desc)
43
+ # Person.sort(:first_name, :age__descend)
44
+ # Person.sort({ fieldName: "FirstName" }, { fieldName: "Age", sortOrder: "descend" })
45
+ #
46
+ def sort(*args)
47
+ with_clone do |r|
48
+ r.sort_params = args.flatten.map { |s| normalize_sort_param(s) }
49
+ end
50
+ end
51
+ alias :order :sort
52
+
53
+ def portal(*args)
54
+ with_clone do |r|
55
+ r.portal_params += args.flatten.map { |p| normalize_portal_param(p) }
56
+ r.portal_params.uniq!
57
+ end
58
+ end
59
+ alias :includes :portal
60
+
61
+ def query(*params)
62
+ with_clone do |r|
63
+ r.query_params += params.flatten.map { |p| normalize_query_params(p) }
64
+ end
65
+ end
66
+
67
+ def omit(params)
68
+ query(params.merge(omit: true))
69
+ end
70
+
71
+ def has_query?
72
+ query_params.present?
73
+ end
74
+
75
+ def find_one
76
+ return super if params[klass.primary_key].present?
77
+ @find_one ||= klass.new_collection_from_result(limit(1).fetch).first
78
+ rescue ::Spyke::ConnectionError => error
79
+ fallback_or_reraise(error, default: nil)
80
+ end
81
+
82
+ private
83
+
84
+ def normalize_sort_param(param)
85
+ if param.kind_of?(Symbol) || param.kind_of?(String)
86
+ _, attr, descend = param.to_s.match(SORT_PARAM_MATCHER).to_a
87
+
88
+ unless field_name = klass.mapped_attributes[attr]
89
+ raise ArgumentError, "Unknown attribute `#{attr}' given to sort as #{param.inspect}. If you want to use a custom sort pass a hash in the Data API format"
90
+ end
91
+
92
+ hash = { fieldName: field_name }
93
+ hash[:sortOrder] = "descend" if descend
94
+ return hash
95
+ end
96
+
97
+ # TODO: Sanitize sort hash param for FM Data API conformity?
98
+ param
99
+ end
100
+
101
+ def normalize_portal_param(param)
102
+ if param.kind_of?(Symbol)
103
+ portal_key, = klass.portal_options.find { |_, opts| opts[:name].to_s == param.to_s }
104
+
105
+ unless portal_key
106
+ raise ArgumentError, "Unknown portal #{param.inspect}. If you want to include a portal not defined in the model pass it as a string instead"
107
+ end
108
+
109
+ return portal_key
110
+ end
111
+
112
+ param
113
+ end
114
+
115
+ def normalize_query_params(params)
116
+ params.each_with_object({}) do |(k, v), normalized|
117
+ if k == :omit || k == "omit"
118
+ # FM Data API wants omit values as strings, e.g. "true" or "false"
119
+ # rather than true/false
120
+ normalized["omit"] = v.to_s
121
+ next
122
+ end
123
+
124
+ # TODO: Raise ArgumentError if an attribute given as symbol isn't defiend
125
+ if k.kind_of?(Symbol) && klass.mapped_attributes.has_key?(k)
126
+ normalized[klass.mapped_attributes[k].to_s] = v
127
+ else
128
+ normalized[k.to_s] = v
129
+ end
130
+ end
131
+ end
132
+
133
+ def with_clone
134
+ clone.tap do |relation|
135
+ yield relation
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,54 @@
1
+ require "fmrest/v1/token_session"
2
+ require "uri"
3
+
4
+ module FmRest
5
+ module V1
6
+ BASE_PATH = "/fmi/data/v1/databases".freeze
7
+
8
+ class << self
9
+ def build_connection(options = FmRest.config, &block)
10
+ base_connection(options) do |conn|
11
+ conn.use TokenSession, options
12
+ conn.request :json
13
+
14
+ if options[:log]
15
+ conn.response :logger, nil, bodies: true, headers: true
16
+ end
17
+
18
+ # Allow overriding the default response middleware
19
+ if block_given?
20
+ yield conn
21
+ else
22
+ conn.response :json
23
+ end
24
+
25
+ conn.adapter Faraday.default_adapter
26
+ end
27
+ end
28
+
29
+ def base_connection(options = FmRest.config, &block)
30
+ # TODO: Make HTTPS optional
31
+ Faraday.new("https://#{options.fetch(:host)}#{BASE_PATH}/#{URI.escape(options.fetch(:database))}/".freeze, &block)
32
+ end
33
+
34
+ def session_path(token = nil)
35
+ url = "sessions"
36
+ url += "/#{token}" if token
37
+ url
38
+ end
39
+
40
+ def record_path(layout, id = nil)
41
+ url = "layouts/#{URI.escape(layout.to_s)}/records"
42
+ url += "/#{id}" if id
43
+ url
44
+ end
45
+
46
+ def find_path(layout)
47
+ "layouts/#{URI.escape(layout.to_s)}/_find"
48
+ end
49
+
50
+ #def globals_path
51
+ #end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,91 @@
1
+ module FmRest
2
+ module V1
3
+ # FM Data API authentication middleware using the credentials strategy
4
+ #
5
+ class TokenSession < Faraday::Middleware
6
+ HEADER_KEY = "Authorization".freeze
7
+
8
+ def initialize(app, options = FmRest.config)
9
+ super(app)
10
+ @options = options
11
+ end
12
+
13
+ # Entry point for the middleware when sending a request
14
+ #
15
+ def call(env)
16
+ set_auth_header(env)
17
+
18
+ request_body = env[:body] # After failure env[:body] is set to the response body
19
+
20
+ @app.call(env).on_complete do |response_env|
21
+ if response_env[:status] == 401 # Unauthorized
22
+ env[:body] = request_body
23
+ token_store.clear
24
+ set_auth_header(env)
25
+ return @app.call(env)
26
+ end
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def set_auth_header(env)
33
+ env.request_headers[HEADER_KEY] = "Bearer #{token}"
34
+ end
35
+
36
+ # Tries to get an existing token from the token store,
37
+ # otherwise requests one through basic auth,
38
+ # otherwise raises an exception.
39
+ #
40
+ def token
41
+ token = token_store.fetch
42
+ return token if token
43
+
44
+ if token = request_token
45
+ token_store.store(token)
46
+ return token
47
+ end
48
+
49
+ # TODO: Make this a custom exception class
50
+ raise "Filemaker auth failed"
51
+ end
52
+
53
+ # Requests a token through basic auth
54
+ #
55
+ def request_token
56
+ resp = auth_connection.post do |req|
57
+ req.url V1.session_path
58
+ req.headers["Content-Type"] = "application/json"
59
+ end
60
+ return resp.body["response"]["token"] if resp.success?
61
+ false
62
+ end
63
+
64
+ def token_store
65
+ @token_store ||= token_store_class.new(@options.fetch(:host), @options.fetch(:database))
66
+ end
67
+
68
+ def token_store_class
69
+ FmRest.token_store ||
70
+ begin
71
+ # TODO: Make this less ugly
72
+ require "fmrest/v1/token_store/memory"
73
+ TokenStore::Memory
74
+ end
75
+ end
76
+
77
+ def auth_connection
78
+ @auth_connection ||= V1.base_connection(@options) do |conn|
79
+ conn.basic_auth @options.fetch(:username), @options.fetch(:password)
80
+
81
+ if @options[:log]
82
+ conn.response :logger, nil, bodies: true, headers: true
83
+ end
84
+
85
+ conn.response :json
86
+ conn.adapter Faraday.default_adapter
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end