fmrest 0.1.0 → 0.2.0

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