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.
- checksums.yaml +7 -0
- data/.gitignore +25 -0
- data/.rspec +3 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +559 -0
- data/Rakefile +6 -0
- data/fmrest.gemspec +33 -0
- data/lib/fmrest.rb +13 -0
- data/lib/fmrest/spyke.rb +11 -0
- data/lib/fmrest/spyke/base.rb +15 -0
- data/lib/fmrest/spyke/json_parser.rb +143 -0
- data/lib/fmrest/spyke/model.rb +23 -0
- data/lib/fmrest/spyke/model/associations.rb +77 -0
- data/lib/fmrest/spyke/model/attributes.rb +198 -0
- data/lib/fmrest/spyke/model/connection.rb +53 -0
- data/lib/fmrest/spyke/model/orm.rb +81 -0
- data/lib/fmrest/spyke/model/uri.rb +28 -0
- data/lib/fmrest/spyke/portal.rb +53 -0
- data/lib/fmrest/spyke/relation.rb +140 -0
- data/lib/fmrest/v1.rb +54 -0
- data/lib/fmrest/v1/token_session.rb +91 -0
- data/lib/fmrest/v1/token_store.rb +6 -0
- data/lib/fmrest/v1/token_store/active_record.rb +73 -0
- data/lib/fmrest/v1/token_store/base.rb +14 -0
- data/lib/fmrest/v1/token_store/memory.rb +26 -0
- data/lib/fmrest/version.rb +3 -0
- metadata +211 -0
@@ -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
|
data/lib/fmrest/v1.rb
ADDED
@@ -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
|