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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.yardopts +1 -0
  4. data/README.md +101 -7
  5. data/fmrest.gemspec +3 -0
  6. data/lib/fmrest.rb +2 -0
  7. data/lib/fmrest/errors.rb +27 -0
  8. data/lib/fmrest/spyke.rb +9 -0
  9. data/lib/fmrest/spyke/base.rb +2 -0
  10. data/lib/fmrest/spyke/container_field.rb +59 -0
  11. data/lib/fmrest/spyke/json_parser.rb +83 -24
  12. data/lib/fmrest/spyke/model.rb +7 -0
  13. data/lib/fmrest/spyke/model/associations.rb +2 -0
  14. data/lib/fmrest/spyke/model/attributes.rb +14 -55
  15. data/lib/fmrest/spyke/model/connection.rb +2 -0
  16. data/lib/fmrest/spyke/model/container_fields.rb +25 -0
  17. data/lib/fmrest/spyke/model/orm.rb +72 -5
  18. data/lib/fmrest/spyke/model/serialization.rb +80 -0
  19. data/lib/fmrest/spyke/model/uri.rb +2 -0
  20. data/lib/fmrest/spyke/portal.rb +2 -0
  21. data/lib/fmrest/spyke/relation.rb +30 -14
  22. data/lib/fmrest/token_store.rb +6 -0
  23. data/lib/fmrest/token_store/active_record.rb +74 -0
  24. data/lib/fmrest/token_store/base.rb +25 -0
  25. data/lib/fmrest/token_store/memory.rb +26 -0
  26. data/lib/fmrest/token_store/redis.rb +45 -0
  27. data/lib/fmrest/v1.rb +10 -49
  28. data/lib/fmrest/v1/connection.rb +57 -0
  29. data/lib/fmrest/v1/container_fields.rb +73 -0
  30. data/lib/fmrest/v1/paths.rb +36 -0
  31. data/lib/fmrest/v1/raise_errors.rb +55 -0
  32. data/lib/fmrest/v1/token_session.rb +32 -12
  33. data/lib/fmrest/v1/token_store/active_record.rb +6 -66
  34. data/lib/fmrest/v1/token_store/memory.rb +6 -19
  35. data/lib/fmrest/v1/utils.rb +94 -0
  36. data/lib/fmrest/version.rb +3 -1
  37. metadata +60 -5
  38. data/lib/fmrest/v1/token_store.rb +0 -6
  39. data/lib/fmrest/v1/token_store/base.rb +0 -14
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module TokenStore
5
+ end
6
+ end
@@ -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
@@ -1,54 +1,15 @@
1
- require "fmrest/v1/token_session"
2
- require "uri"
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
- 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
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