fmrest 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|