fmrest 0.1.0 → 0.2.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 +4 -4
- data/.gitignore +1 -0
- data/.yardopts +1 -0
- data/README.md +101 -7
- data/fmrest.gemspec +3 -0
- data/lib/fmrest.rb +2 -0
- data/lib/fmrest/errors.rb +27 -0
- data/lib/fmrest/spyke.rb +9 -0
- data/lib/fmrest/spyke/base.rb +2 -0
- data/lib/fmrest/spyke/container_field.rb +59 -0
- data/lib/fmrest/spyke/json_parser.rb +83 -24
- data/lib/fmrest/spyke/model.rb +7 -0
- data/lib/fmrest/spyke/model/associations.rb +2 -0
- data/lib/fmrest/spyke/model/attributes.rb +14 -55
- data/lib/fmrest/spyke/model/connection.rb +2 -0
- data/lib/fmrest/spyke/model/container_fields.rb +25 -0
- data/lib/fmrest/spyke/model/orm.rb +72 -5
- data/lib/fmrest/spyke/model/serialization.rb +80 -0
- data/lib/fmrest/spyke/model/uri.rb +2 -0
- data/lib/fmrest/spyke/portal.rb +2 -0
- data/lib/fmrest/spyke/relation.rb +30 -14
- data/lib/fmrest/token_store.rb +6 -0
- data/lib/fmrest/token_store/active_record.rb +74 -0
- data/lib/fmrest/token_store/base.rb +25 -0
- data/lib/fmrest/token_store/memory.rb +26 -0
- data/lib/fmrest/token_store/redis.rb +45 -0
- data/lib/fmrest/v1.rb +10 -49
- data/lib/fmrest/v1/connection.rb +57 -0
- data/lib/fmrest/v1/container_fields.rb +73 -0
- data/lib/fmrest/v1/paths.rb +36 -0
- data/lib/fmrest/v1/raise_errors.rb +55 -0
- data/lib/fmrest/v1/token_session.rb +32 -12
- data/lib/fmrest/v1/token_store/active_record.rb +6 -66
- data/lib/fmrest/v1/token_store/memory.rb +6 -19
- data/lib/fmrest/v1/utils.rb +94 -0
- data/lib/fmrest/version.rb +3 -1
- metadata +60 -5
- data/lib/fmrest/v1/token_store.rb +0 -6
- data/lib/fmrest/v1/token_store/base.rb +0 -14
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fmrest/token_store/base"
|
4
|
+
require "active_record"
|
5
|
+
|
6
|
+
module FmRest
|
7
|
+
module TokenStore
|
8
|
+
# Heavily inspired by Moneta's ActiveRecord store:
|
9
|
+
#
|
10
|
+
# https://github.com/minad/moneta/blob/master/lib/moneta/adapters/activerecord.rb
|
11
|
+
#
|
12
|
+
class ActiveRecord < Base
|
13
|
+
DEFAULT_TABLE_NAME = "fmrest_session_tokens".freeze
|
14
|
+
|
15
|
+
@connection_lock = ::Mutex.new
|
16
|
+
class << self
|
17
|
+
attr_reader :connection_lock
|
18
|
+
end
|
19
|
+
|
20
|
+
attr_reader :connection_pool, :model
|
21
|
+
|
22
|
+
delegate :with_connection, to: :connection_pool
|
23
|
+
|
24
|
+
def initialize(options = {})
|
25
|
+
super
|
26
|
+
|
27
|
+
@connection_pool = ::ActiveRecord::Base.connection_pool
|
28
|
+
|
29
|
+
create_table
|
30
|
+
|
31
|
+
@model = Class.new(::ActiveRecord::Base)
|
32
|
+
@model.table_name = table_name
|
33
|
+
end
|
34
|
+
|
35
|
+
def delete(key)
|
36
|
+
model.where(scope: key).delete_all
|
37
|
+
end
|
38
|
+
|
39
|
+
def load(key)
|
40
|
+
model.where(scope: key).pluck(:token).first
|
41
|
+
end
|
42
|
+
|
43
|
+
def store(key, value)
|
44
|
+
record = model.find_or_initialize_by(scope: key)
|
45
|
+
record.token = value
|
46
|
+
record.save!
|
47
|
+
value
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def create_table
|
53
|
+
with_connection do |conn|
|
54
|
+
return if conn.table_exists?(table_name)
|
55
|
+
|
56
|
+
# Prevent multiple connections from attempting to create the table simultaneously.
|
57
|
+
self.class.connection_lock.synchronize do
|
58
|
+
conn.create_table(table_name, id: false) do |t|
|
59
|
+
t.string :scope, null: false
|
60
|
+
t.string :token, null: false
|
61
|
+
t.datetime :updated_at
|
62
|
+
end
|
63
|
+
conn.add_index(table_name, :scope, unique: true)
|
64
|
+
conn.add_index(table_name, [:scope, :token])
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def table_name
|
70
|
+
options[:table_name] || DEFAULT_TABLE_NAME
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FmRest
|
4
|
+
module TokenStore
|
5
|
+
class Base
|
6
|
+
attr_reader :options
|
7
|
+
|
8
|
+
def initialize(options = {})
|
9
|
+
@options = options
|
10
|
+
end
|
11
|
+
|
12
|
+
def load(key)
|
13
|
+
raise "Not implemented"
|
14
|
+
end
|
15
|
+
|
16
|
+
def store(key, value)
|
17
|
+
raise "Not implemented"
|
18
|
+
end
|
19
|
+
|
20
|
+
def delete(key)
|
21
|
+
raise "Not implemented"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fmrest/token_store/base"
|
4
|
+
|
5
|
+
module FmRest
|
6
|
+
module TokenStore
|
7
|
+
class Memory < Base
|
8
|
+
def initialize(*args)
|
9
|
+
super
|
10
|
+
@tokens = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
def delete(key)
|
14
|
+
@tokens.delete(key)
|
15
|
+
end
|
16
|
+
|
17
|
+
def load(key)
|
18
|
+
@tokens[key]
|
19
|
+
end
|
20
|
+
|
21
|
+
def store(key, value)
|
22
|
+
@tokens[key] = value
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fmrest/token_store/base"
|
4
|
+
require "redis" unless defined?(MockRedis)
|
5
|
+
|
6
|
+
module FmRest
|
7
|
+
module TokenStore
|
8
|
+
class Redis < Base
|
9
|
+
DEFAULT_PREFIX = "fmrest-token:".freeze
|
10
|
+
|
11
|
+
STORE_OPTIONS = [:redis, :prefix].freeze
|
12
|
+
|
13
|
+
def initialize(options = {})
|
14
|
+
super
|
15
|
+
@redis = @options[:redis] || ::Redis.new(options_for_redis)
|
16
|
+
@prefix = @options[:prefix] || DEFAULT_PREFIX
|
17
|
+
end
|
18
|
+
|
19
|
+
def load(key)
|
20
|
+
@redis.get(prefix_key(key))
|
21
|
+
end
|
22
|
+
|
23
|
+
def store(key, value)
|
24
|
+
@redis.set(prefix_key(key), value)
|
25
|
+
value
|
26
|
+
end
|
27
|
+
|
28
|
+
def delete(key)
|
29
|
+
@redis.del(prefix_key(key))
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def options_for_redis
|
35
|
+
@options.dup.tap do |options|
|
36
|
+
STORE_OPTIONS.each { |opt| options.delete(opt) }
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def prefix_key(key)
|
41
|
+
"#{@prefix}#{key}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
data/lib/fmrest/v1.rb
CHANGED
@@ -1,54 +1,15 @@
|
|
1
|
-
|
2
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fmrest/v1/connection"
|
4
|
+
require "fmrest/v1/paths"
|
5
|
+
require "fmrest/v1/container_fields"
|
6
|
+
require "fmrest/v1/utils"
|
3
7
|
|
4
8
|
module FmRest
|
5
9
|
module V1
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
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
|
10
|
+
extend Connection
|
11
|
+
extend Paths
|
12
|
+
extend ContainerFields
|
13
|
+
extend Utils
|
53
14
|
end
|
54
15
|
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "uri"
|
4
|
+
require "fmrest/v1/token_session"
|
5
|
+
require "fmrest/v1/raise_errors"
|
6
|
+
|
7
|
+
module FmRest
|
8
|
+
module V1
|
9
|
+
module Connection
|
10
|
+
BASE_PATH = "/fmi/data/v1/databases".freeze
|
11
|
+
|
12
|
+
def build_connection(options = FmRest.config, &block)
|
13
|
+
base_connection(options) do |conn|
|
14
|
+
conn.use RaiseErrors
|
15
|
+
conn.use TokenSession, options
|
16
|
+
|
17
|
+
# The EncodeJson and Multipart middlewares only encode the request
|
18
|
+
# when the content type matches, so we can have them both here and
|
19
|
+
# still play nice with each other, we just need to set the content
|
20
|
+
# type to multipart/form-data when we want to submit a container
|
21
|
+
# field
|
22
|
+
conn.request :multipart
|
23
|
+
conn.request :json
|
24
|
+
|
25
|
+
if options[:log]
|
26
|
+
conn.response :logger, nil, bodies: true, headers: true
|
27
|
+
end
|
28
|
+
|
29
|
+
# Allow overriding the default response middleware
|
30
|
+
if block_given?
|
31
|
+
yield conn
|
32
|
+
else
|
33
|
+
conn.response :json
|
34
|
+
end
|
35
|
+
|
36
|
+
conn.adapter Faraday.default_adapter
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def base_connection(options = FmRest.config, &block)
|
41
|
+
host = options.fetch(:host)
|
42
|
+
|
43
|
+
# Default to HTTPS
|
44
|
+
scheme = "https"
|
45
|
+
|
46
|
+
if host.match(/\Ahttps?:\/\//)
|
47
|
+
uri = URI(host)
|
48
|
+
host = uri.hostname
|
49
|
+
host += ":#{uri.port}" if uri.port != uri.default_port
|
50
|
+
scheme = uri.scheme
|
51
|
+
end
|
52
|
+
|
53
|
+
Faraday.new("#{scheme}://#{host}#{BASE_PATH}/#{URI.escape(options.fetch(:database))}/".freeze, &block)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FmRest
|
4
|
+
module V1
|
5
|
+
module ContainerFields
|
6
|
+
DEFAULT_UPLOAD_CONTENT_TYPE = "application/octet-stream".freeze
|
7
|
+
|
8
|
+
# Given a container field URL it tries to fetch it and returns an IO
|
9
|
+
# object with its body content (see Ruby's OpenURI for how the IO object
|
10
|
+
# is extended with useful HTTP response information).
|
11
|
+
#
|
12
|
+
# This method uses Net::HTTP and OpenURI instead of Faraday.
|
13
|
+
#
|
14
|
+
# @raise [FmRest::ContainerFieldError] if any step fails
|
15
|
+
# @param container_field_url [String] the URL to the container to
|
16
|
+
# download
|
17
|
+
# @return [IO] the contents of the container
|
18
|
+
def fetch_container_data(container_field_url)
|
19
|
+
require "open-uri"
|
20
|
+
|
21
|
+
begin
|
22
|
+
url = URI(container_field_url)
|
23
|
+
rescue ::URI::InvalidURIError
|
24
|
+
raise FmRest::ContainerFieldError, "Invalid container field URL `#{container_field_url}'"
|
25
|
+
end
|
26
|
+
|
27
|
+
# Make sure we don't try to open anything on the file:/ URI scheme
|
28
|
+
unless url.scheme.match(/\Ahttps?\Z/)
|
29
|
+
raise FmRest::ContainerFieldError, "Container URL is not HTTP (#{container_field_url})"
|
30
|
+
end
|
31
|
+
|
32
|
+
require "net/http"
|
33
|
+
|
34
|
+
# Requesting the container URL with no cookie set will respond with a
|
35
|
+
# redirect and a session cookie
|
36
|
+
cookie_response = ::Net::HTTP.get_response(url)
|
37
|
+
|
38
|
+
unless cookie = cookie_response["Set-Cookie"]
|
39
|
+
raise FmRest::ContainerFieldError, "Container field's initial request didn't return a session cookie, the URL may be stale (try downloading it again immediately after retrieving the record)"
|
40
|
+
end
|
41
|
+
|
42
|
+
# Now request the URL again with the proper session cookie using
|
43
|
+
# OpenURI, which wraps the response in an IO object which also responds
|
44
|
+
# to #content_type
|
45
|
+
url.open("Cookie" => cookie)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Handles the core logic of uploading a file into a container field
|
49
|
+
#
|
50
|
+
# @param connection [Faraday] the Faraday connection to use
|
51
|
+
# @param container_path [String] the path to the container
|
52
|
+
# @param filename_or_io [String, IO] a path to the file to upload or an
|
53
|
+
# IO object
|
54
|
+
# @param options [Hash]
|
55
|
+
# @option options [String] :content_type (DEFAULT_UPLOAD_CONTENT_TYPE)
|
56
|
+
# The content type for the uploaded file
|
57
|
+
# @option options [String] :filename The filename to use for the uploaded
|
58
|
+
# file, defaults to `filename_or_io.original_filename` if available
|
59
|
+
def upload_container_data(connection, container_path, filename_or_io, options = {})
|
60
|
+
content_type = options[:content_type] || DEFAULT_UPLOAD_CONTENT_TYPE
|
61
|
+
|
62
|
+
connection.post do |request|
|
63
|
+
request.url container_path
|
64
|
+
request.headers['Content-Type'] = ::Faraday::Request::Multipart.mime_type
|
65
|
+
|
66
|
+
filename = options[:filename] || filename_or_io.try(:original_filename)
|
67
|
+
|
68
|
+
request.body = { upload: Faraday::UploadIO.new(filename_or_io, content_type, filename) }
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "uri"
|
4
|
+
|
5
|
+
module FmRest
|
6
|
+
module V1
|
7
|
+
module Paths
|
8
|
+
def session_path(token = nil)
|
9
|
+
url = "sessions"
|
10
|
+
url += "/#{token}" if token
|
11
|
+
url
|
12
|
+
end
|
13
|
+
|
14
|
+
def record_path(layout, id = nil)
|
15
|
+
url = "layouts/#{URI.escape(layout.to_s)}/records"
|
16
|
+
url += "/#{id}" if id
|
17
|
+
url
|
18
|
+
end
|
19
|
+
|
20
|
+
def container_field_path(layout, id, field_name, field_repetition = 1)
|
21
|
+
url = record_path(layout, id)
|
22
|
+
url += "/containers/#{URI.escape(field_name.to_s)}"
|
23
|
+
url += "/#{field_repetition}" if field_repetition
|
24
|
+
url
|
25
|
+
end
|
26
|
+
|
27
|
+
def find_path(layout)
|
28
|
+
"layouts/#{URI.escape(layout.to_s)}/_find"
|
29
|
+
end
|
30
|
+
|
31
|
+
def globals_path
|
32
|
+
"globals"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fmrest/errors"
|
4
|
+
|
5
|
+
module FmRest
|
6
|
+
module V1
|
7
|
+
# FM Data API response middleware for raising exceptions on API response
|
8
|
+
# errors
|
9
|
+
#
|
10
|
+
# https://fmhelp.filemaker.com/help/17/fmp/en/index.html#page/FMP_Help/error-codes.html
|
11
|
+
#
|
12
|
+
class RaiseErrors < Faraday::Response::Middleware
|
13
|
+
# https://fmhelp.filemaker.com/help/17/fmp/en/index.html#page/FMP_Help/error-codes.html
|
14
|
+
ERROR_RANGES = {
|
15
|
+
-1 => APIError::UnknownError,
|
16
|
+
100 => APIError::ResourceMissingError,
|
17
|
+
101 => APIError::RecordMissingError,
|
18
|
+
102..199 => APIError::ResourceMissingError,
|
19
|
+
200..299 => APIError::AccountError,
|
20
|
+
300..399 => APIError::LockError,
|
21
|
+
400..499 => APIError::ParameterError,
|
22
|
+
500..599 => APIError::ValidationError,
|
23
|
+
800..899 => APIError::SystemError,
|
24
|
+
1200..1299 => APIError::ScriptError,
|
25
|
+
1400..1499 => APIError::ODBCError
|
26
|
+
}
|
27
|
+
|
28
|
+
def on_complete(env)
|
29
|
+
# Sniff for either straight JSON parsing or Spyke's format
|
30
|
+
if env.body[:metadata] && env.body[:metadata][:messages]
|
31
|
+
check_errors(env.body[:metadata][:messages])
|
32
|
+
elsif env.body["messages"]
|
33
|
+
check_errors(env.body["messages"])
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def check_errors(messages)
|
40
|
+
messages.each do |message|
|
41
|
+
error_code = (message["code"] || message[:code]).to_i
|
42
|
+
|
43
|
+
# Code 0 means "No Error"
|
44
|
+
next if error_code.zero?
|
45
|
+
|
46
|
+
error_message = message["message"] || message[:message]
|
47
|
+
|
48
|
+
*, exception_class = ERROR_RANGES.find { |k, v| k === error_code }
|
49
|
+
|
50
|
+
raise (exception_class || APIError).new(error_code, error_message)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|