fmrest 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,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