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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3fd72c8d862e6225e0997c1b5a3f68597304dccba912d4230f92495536bf528
4
- data.tar.gz: 528e8673d20825ed9d1dacacad8b59493099a8b18f8f530baaac34038ba3e8ce
3
+ metadata.gz: d9ac0ed8884efb3c03f32de33e934f5ba7298ff17fd6f2531bc31c09fff53654
4
+ data.tar.gz: c1b29afaf072953515843db4c95e9e7426662a64bc8df3aa88ec99dc7c3715f6
5
5
  SHA512:
6
- metadata.gz: 38fed0930933ce59b32054459f41a9aeb7b7d1b4c7ae6e5ead53e331452c2180cab4e1a08b582f5729df745e94db85ae4b5fdf448aacebce9dd772e9073f5d53
7
- data.tar.gz: 00ab8ffd3e3dd0ac1ab8a91eccc8e60f0edd9844e77c32bf2775ac03fbd610e969315e6398d499ca8cf919c78c9dc7baa45b38109ee1f328241b46bca01769a5
6
+ metadata.gz: 685dbf33e5fb4fd929e7fd5dba1a35ba6791f81590dd34e3abef16ea129b1603403e1c5fb09ea82b536e501ff7ab072a62e24b85223a53ab85602f757e825831
7
+ data.tar.gz: 25fd66ec79dfb9356c7939641e97ef65e6ba49decfc289fd4cdfc373de9656392b4889d143f91bf5df5a5c9e020913c068e1bfcdfe9573a16063a598cf86b8b9
data/.gitignore CHANGED
@@ -20,6 +20,7 @@ tmp
20
20
  *.o
21
21
  *.a
22
22
  mkmf.log
23
+ spec/db/*
23
24
 
24
25
  # rspec failure tracking
25
26
  .rspec_status
@@ -0,0 +1 @@
1
+ --markup markdown
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # fmrest-ruby
2
2
 
3
+ <a href="https://rubygems.org/gems/fmrest"><img src="https://badge.fury.io/rb/fmrest.svg?style=flat" alt="Gem Version"></a>
4
+
3
5
  A Ruby client for
4
6
  [FileMaker 17's Data API](https://fmhelp.filemaker.com/docs/17/en/dataapi/)
5
7
  using
@@ -20,6 +22,9 @@ Add this line to your Gemfile:
20
22
 
21
23
  ```ruby
22
24
  gem 'fmrest'
25
+
26
+ # Optional (for ORM features)
27
+ gem 'spyke'
23
28
  ```
24
29
 
25
30
  ## Basic usage
@@ -63,23 +68,59 @@ By default fmrest-ruby will use a memory-based store for the session tokens.
63
68
  This is generally good enough for development, but not good enough for
64
69
  production, as in-memory tokens aren't shared across threads/processes.
65
70
 
66
- Besides the default memory token store an ActiveRecord-based token store is
67
- included with the gem (maybe more to come later).
71
+ Besides the default token store the following token stores are bundled with fmrest-ruby:
72
+
73
+ ### ActiveRecord
68
74
 
69
75
  On Rails apps already using ActiveRecord setting up this token store should be
70
76
  dead simple:
71
77
 
72
78
  ```ruby
73
79
  # config/initializers/fmrest.rb
74
- require "fmrest/v1/token_store/active_record"
80
+ require "fmrest/token_store/active_record"
75
81
 
76
- FmRest.token_store = FmRest::V1::TokenStore::ActiveRecord
82
+ FmRest.token_store = FmRest::TokenStore::ActiveRecord
77
83
  ```
78
84
 
79
85
  No migrations are needed, the token store table will be created automatically
80
- when needed, defaulting to the table name "fmrest_session_tokens".
86
+ when needed, defaulting to the table name "fmrest_session_tokens". If you want
87
+ to change the table name you can do so by initializing the token store and
88
+ passing it the `:table_name` option:
89
+
90
+ ```ruby
91
+ FmRest.token_store = FmRest::TokenStore::ActiveRecord.new(table_name: "my_token_store")
92
+ ```
93
+
94
+ ### Redis
95
+
96
+ To use the Redis token store do:
97
+
98
+ ```ruby
99
+ require "fmrest/token_store/redis"
100
+
101
+ FmRest.token_store = FmRest::TokenStore::Redis
102
+ ```
103
+
104
+ You can also initialize it with the following options:
105
+
106
+ * `:redis` - A `Redis` object to use as connection, if ommited a new `Redis` object will be created with remaining options
107
+ * `:prefix` - The prefix to use for token keys, by default `"fmrest-token:"`
108
+ * Any other options will be passed to `Redis.new` if `:redis` isn't provided
109
+
110
+ Examples:
111
+
112
+ ```ruby
113
+ # Passing a Redis connection explicitly
114
+ FmRest.token_store = FmRest::TokenStore::Redis.new(redis: Redis.new, prefix: "my-fancy-prefix:")
81
115
 
82
- ## Spyke support
116
+ # Passing options for Redis.new
117
+ FmRest.token_store = FmRest::TokenStore::Redis.new(prefix: "my-fancy-prefix:", host: "10.0.1.1", port: 6380, db: 15)
118
+ ```
119
+
120
+ **NOTE:** redis-rb is not included as a gem dependency of fmrest-ruby, so you'll
121
+ have to add it to your Gemfile.
122
+
123
+ ## Spyke support (ActiveRecord-like ORM)
83
124
 
84
125
  [Spyke](https://github.com/balvig/spyke) is an ActiveRecord-like gem for
85
126
  building REST models. fmrest-ruby has Spyke support out of the box, although
@@ -348,18 +389,24 @@ class Kitty < Spyke::Base
348
389
  end
349
390
  ```
350
391
 
392
+ #### .limit
393
+
351
394
  `.limit` sets the limit for get and find request:
352
395
 
353
396
  ```ruby
354
397
  Kitty.limit(10)
355
398
  ```
356
399
 
400
+ #### .offset
401
+
357
402
  `.offset` sets the offset for get and find requests:
358
403
 
359
404
  ```ruby
360
405
  Kitty.offset(10)
361
406
  ```
362
407
 
408
+ #### .sort
409
+
363
410
  `.sort` (or `.order`) sets sorting options for get and find requests:
364
411
 
365
412
  ```ruby
@@ -375,6 +422,8 @@ Kitty.sort(:name, :age!)
375
422
  Kitty.sort(:name, :age__desc)
376
423
  ```
377
424
 
425
+ #### .portal
426
+
378
427
  `.portal` (or `.includes`) sets the portals to fetch for get and find requests
379
428
  (this recognizes portals defined with `has_portal`):
380
429
 
@@ -383,6 +432,8 @@ Kitty.portal(:toys)
383
432
  Kitty.includes(:toys) # alias method
384
433
  ```
385
434
 
435
+ #### .query
436
+
386
437
  `.query` sets query conditions for a find request (and supports attributes as
387
438
  defined with `attributes`):
388
439
 
@@ -407,6 +458,8 @@ Kitty.query({ name: "Mr. Fluffers" }, { name: "Coronel Chai Latte" })
407
458
  # JSON -> {"query": [{"CatName": "Mr. Fluffers"}, {"CatName": "Coronel Chai Latte"}]}
408
459
  ```
409
460
 
461
+ #### .omit
462
+
410
463
  `.omit` works like `.query` but excludes matches:
411
464
 
412
465
  ```ruby
@@ -421,6 +474,8 @@ Kitty.query(name: "Captain Whiskers", omit: true)
421
474
  # JSON -> {"query": [{"CatName": "Captain Whiskers", "omit": "true"}]}
422
475
  ```
423
476
 
477
+ #### Other notes on querying
478
+
424
479
  You can chain all query methods together:
425
480
 
426
481
  ```ruby
@@ -463,6 +518,43 @@ instead of `POST ../:layout/_find`).
463
518
  Kitty.find(89) # => <Kitty...>
464
519
  ```
465
520
 
521
+ ### Container fields
522
+
523
+ You can define container fields on your model class with `container`:
524
+
525
+ ```ruby
526
+ class Kitty < FmRest::Spyke::Base
527
+ container :photo, field_name: "Vet Card Photo ID"
528
+ end
529
+ ```
530
+
531
+ `:field_name` specifies the original field in the FM layout and is optional, if
532
+ not given it will default to the name of your attribute (just `:photo` in this
533
+ example).
534
+
535
+ (Note that you don't need to define container fields with `attributes` in
536
+ addition to the `container` definition.)
537
+
538
+ This will provide you with the following instance methods:
539
+
540
+ ```ruby
541
+ kitty = Kitty.new
542
+
543
+ kitty.photo.url # The URL of the container file on the FileMaker server
544
+
545
+ kitty.photo.download # Download the contents of the container as an IO object
546
+
547
+ kitty.photo.upload(filename_or_io) # Upload a file to the container
548
+ ```
549
+
550
+ `upload` also accepts an options hash with the following options:
551
+
552
+ * `:repetition` - Sets the field repetition
553
+ * `:filename` - The filename to use when uploading (defaults to
554
+ `filename_or_io.original_filename` if available)
555
+ * `:content_type` - The MIME content type to use (defaults to
556
+ `application/octet-stream`)
557
+
466
558
  ## Logging
467
559
 
468
560
  If using fmrest-ruby + Spyke in a Rails app pretty log output will be set up
@@ -510,11 +602,13 @@ end
510
602
 
511
603
  ## TODO
512
604
 
605
+ - [ ] Support for FM18 features
513
606
  - [ ] Better/simpler-to-use core Ruby API
514
607
  - [ ] Better API documentation and README
515
608
  - [ ] Oauth support
516
609
  - [ ] Support for portal limit and offset
517
- - [ ] More options for token storage
610
+ - [x] More options for token storage
611
+ - [x] Support for container fields
518
612
  - [x] Optional logging
519
613
  - [x] FmRest::Spyke::Base class for single inheritance (as alternative for mixin)
520
614
  - [x] Specs
@@ -30,4 +30,7 @@ Gem::Specification.new do |spec|
30
30
  spec.add_development_dependency "webmock"
31
31
  spec.add_development_dependency "multi_json"
32
32
  spec.add_development_dependency "pry-byebug"
33
+ spec.add_development_dependency "activerecord"
34
+ spec.add_development_dependency "sqlite3", "~> 1.3.6"
35
+ spec.add_development_dependency "mock_redis"
33
36
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "faraday"
2
4
  require "faraday_middleware"
3
5
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ class Error < StandardError; end
5
+
6
+ class APIError < Error
7
+ attr_reader :code
8
+
9
+ def initialize(code, message = nil)
10
+ @code = code
11
+ super("FileMaker Data API responded with error #{code}: #{message}")
12
+ end
13
+ end
14
+
15
+ class APIError::UnknownError < APIError; end # error code -1
16
+ class APIError::ResourceMissingError < APIError; end # error codes 100..199
17
+ class APIError::RecordMissingError < APIError::ResourceMissingError; end
18
+ class APIError::AccountError < APIError; end # error codes 200..299
19
+ class APIError::LockError < APIError; end # error codes 300..399
20
+ class APIError::ParameterError < APIError; end # error codes 400..499
21
+ class APIError::ValidationError < APIError; end # error codes 500..599
22
+ class APIError::SystemError < APIError; end # error codes 800..899
23
+ class APIError::ScriptError < APIError; end # error codes 1200..1299
24
+ class APIError::ODBCError < APIError; end # error codes 1400..1499
25
+
26
+ class ContainerFieldError < Error; end
27
+ end
@@ -1,3 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "spyke"
5
+ rescue LoadError => e
6
+ e.message << " (Did you include Spyke in your Gemfile?)" unless e.message.frozen?
7
+ raise e
8
+ end
9
+
1
10
  require "fmrest/spyke/json_parser"
2
11
  require "fmrest/spyke/model"
3
12
  require "fmrest/spyke/base"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module FmRest
2
4
  module Spyke
3
5
  class Base < ::Spyke::Base
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FmRest
4
+ module Spyke
5
+ class ContainerField
6
+
7
+ # @return [String] the name of the container field
8
+ attr_reader :name
9
+
10
+ # @param base [FmRest::Spyke::Base] the record this container belongs to
11
+ # @param name [Symbol] the name of the container field
12
+ def initialize(base, name)
13
+ @base = base
14
+ @name = name
15
+ end
16
+
17
+ # @return [String] the URL for the container
18
+ def url
19
+ @base.attributes[name]
20
+ end
21
+
22
+ # @return (see FmRest::V1::ContainerFields#fetch_container_data)
23
+ def download
24
+ FmRest::V1.fetch_container_data(url)
25
+ end
26
+
27
+ # @param filename_or_io [String, IO] a path to the file to upload or an
28
+ # IO object
29
+ # @param options [Hash]
30
+ # @option options [Integer] :repetition (1) The repetition to pass to the
31
+ # upload URL
32
+ # @option (see FmRest::V1::ContainerFields#upload_container_data)
33
+ def upload(filename_or_io, options = {})
34
+ raise ArgumentError, "Record needs to be saved before uploading to a container field" unless @base.persisted?
35
+
36
+ response =
37
+ FmRest::V1.upload_container_data(
38
+ @base.class.connection,
39
+ upload_path(options[:repetition] || 1),
40
+ filename_or_io,
41
+ options
42
+ )
43
+
44
+ # Update mod id on record
45
+ @base.mod_id = response.body[:data][:mod_id]
46
+
47
+ true
48
+ end
49
+
50
+ private
51
+
52
+ # @param repetition [Integer]
53
+ # @return [String] the path for uploading a file to the container
54
+ def upload_path(repetition)
55
+ FmRest::V1.container_field_path(@base.class.layout, @base.id, name, repetition)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -1,31 +1,41 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module FmRest
2
4
  module Spyke
5
+ # Response Faraday middleware for converting FM API's response JSON into
6
+ # Spyke's expected format
3
7
  class JsonParser < ::Faraday::Response::Middleware
4
8
  SINGLE_RECORD_RE = %r(/records/\d+\Z).freeze
9
+ MULTIPLE_RECORDS_RE = %r(/records\Z).freeze
5
10
  FIND_RECORDS_RE = %r(/_find\b).freeze
6
11
 
12
+ VALIDATION_ERROR_RANGE = 500..599
13
+
14
+ # @param app [#call]
15
+ # @param model [Class<FmRest::Spyke::Base>]
7
16
  def initialize(app, model)
8
17
  super(app)
9
18
  @model = model
10
19
  end
11
20
 
21
+ # @param env [Faraday::Env]
12
22
  def on_complete(env)
13
23
  json = parse_json(env.body)
14
24
 
15
- env.body =
16
- if env.method == :get || find_results?(env.url)
17
- if single_record_url?(env.url)
18
- prepare_single_record(json)
19
- else
20
- prepare_collection(json)
21
- end
22
- else
23
- prepare_save_response(json)
24
- end
25
+ case
26
+ when single_record_request?(env)
27
+ env.body = prepare_single_record(json)
28
+ when multiple_records_request?(env), find_request?(env)
29
+ env.body = prepare_collection(json)
30
+ when create_request?(env), update_request?(env), delete_request?(env)
31
+ env.body = prepare_save_response(json)
32
+ end
25
33
  end
26
34
 
27
35
  private
28
36
 
37
+ # @param json [Hash]
38
+ # @return [Hash] the response in Spyke format
29
39
  def prepare_save_response(json)
30
40
  response = json[:response]
31
41
 
@@ -33,33 +43,53 @@ module FmRest
33
43
  data[:mod_id] = response[:modId].to_i if response[:modId]
34
44
  data[:id] = response[:recordId].to_i if response[:recordId]
35
45
 
36
- base_hash(json).merge!(data: data)
46
+ build_base_hash(json, true).merge!(data: data)
37
47
  end
38
48
 
49
+ # (see #prepare_save_response)
39
50
  def prepare_single_record(json)
40
51
  data =
41
52
  json[:response][:data] &&
42
53
  prepare_record_data(json[:response][:data].first)
43
54
 
44
- base_hash(json).merge!(data: data)
55
+ build_base_hash(json).merge!(data: data)
45
56
  end
46
57
 
58
+ # (see #prepare_save_response)
47
59
  def prepare_collection(json)
48
60
  data =
49
61
  json[:response][:data] &&
50
62
  json[:response][:data].map { |record_data| prepare_record_data(record_data) }
51
63
 
52
- base_hash(json).merge!(data: data)
64
+ build_base_hash(json).merge!(data: data)
53
65
  end
54
66
 
55
- def base_hash(json)
67
+ # @param json [Hash]
68
+ # @param include_errors [Boolean]
69
+ # @return [Hash] the skeleton structure for a Spyke-formatted response
70
+ def build_base_hash(json, include_errors = false)
56
71
  {
57
72
  metadata: { messages: json[:messages] },
58
- errors: {}
73
+ errors: include_errors ? prepare_errors(json) : {}
59
74
  }
60
75
  end
61
76
 
62
- # json_data is expected in this format:
77
+ # @param json [Hash]
78
+ # @return [Hash] the errors hash in Spyke format
79
+ def prepare_errors(json)
80
+ # Code 0 means "No Error"
81
+ # https://fmhelp.filemaker.com/help/17/fmp/en/index.html#page/FMP_Help/error-codes.html
82
+ return {} if json[:messages][0][:code].to_i == 0
83
+
84
+ json[:messages].each_with_object(base: []) do |message, hash|
85
+ # Only include validation errors
86
+ next unless VALIDATION_ERROR_RANGE.include?(message[:code].to_i)
87
+
88
+ hash[:base] << "#{message[:message]} (#{message[:code]})"
89
+ end
90
+ end
91
+
92
+ # `json_data` is expected in this format:
63
93
  #
64
94
  # {
65
95
  # "fieldData": {
@@ -83,6 +113,8 @@ module FmRest
83
113
  # "recordId": <Unique_internal_ID_for_this_record>
84
114
  # }
85
115
  #
116
+ # @param json_data [Hash]
117
+ # @return [Hash] the record data in Spyke format
86
118
  def prepare_record_data(json_data)
87
119
  out = { id: json_data[:recordId].to_i, mod_id: json_data[:modId].to_i }
88
120
  out.merge!(json_data[:fieldData])
@@ -90,17 +122,19 @@ module FmRest
90
122
  out
91
123
  end
92
124
 
93
- # Extracts recordId and strips the PortalName:: field prefix for each
125
+ # Extracts `recordId` and strips the `"PortalName::"` field prefix for each
94
126
  # portal
95
127
  #
96
- # Sample json_portal_data:
128
+ # Sample `json_portal_data`:
97
129
  #
98
130
  # "portalData": {
99
131
  # "Orders":[
100
- # { "Orders::DeliveryDate":"3/7/2017", "recordId":"23" }
132
+ # { "Orders::DeliveryDate": "3/7/2017", "recordId": "23" }
101
133
  # ]
102
134
  # }
103
135
  #
136
+ # @param json_portal_data [Hash]
137
+ # @return [Hash] the portal data in Spyke format
104
138
  def prepare_portal_data(json_portal_data)
105
139
  json_portal_data.each_with_object({}) do |(portal_name, portal_records), out|
106
140
  portal_options = @model.portal_options[portal_name.to_s] || {}
@@ -115,7 +149,7 @@ module FmRest
115
149
 
116
150
  portal_fields.each do |k, v|
117
151
  next if :recordId == k || :modId == k
118
- attributes[k.to_s.gsub(prefix_matcher, "")] = v
152
+ attributes[k.to_s.gsub(prefix_matcher, "").to_sym] = v
119
153
  end
120
154
 
121
155
  attributes
@@ -123,14 +157,39 @@ module FmRest
123
157
  end
124
158
  end
125
159
 
126
- def find_results?(url)
127
- url.path.match(FIND_RECORDS_RE)
160
+ # @param env [Faraday::Env]
161
+ # @return [Boolean]
162
+ def single_record_request?(env)
163
+ env.method == :get && env.url.path.match(SINGLE_RECORD_RE)
164
+ end
165
+
166
+ # (see #single_record_request?)
167
+ def multiple_records_request?(env)
168
+ env.method == :get && env.url.path.match(MULTIPLE_RECORDS_RE)
169
+ end
170
+
171
+ # (see #single_record_request?)
172
+ def find_request?(env)
173
+ env.method == :post && env.url.path.match(FIND_RECORDS_RE)
174
+ end
175
+
176
+ # (see #single_record_request?)
177
+ def update_request?(env)
178
+ env.method == :patch && env.url.path.match(SINGLE_RECORD_RE)
179
+ end
180
+
181
+ # (see #single_record_request?)
182
+ def create_request?(env)
183
+ env.method == :post && env.url.path.match(MULTIPLE_RECORDS_RE)
128
184
  end
129
185
 
130
- def single_record_url?(url)
131
- url.path.match(SINGLE_RECORD_RE)
186
+ # (see #single_record_request?)
187
+ def delete_request?(env)
188
+ env.method == :delete && env.url.path.match(SINGLE_RECORD_RE)
132
189
  end
133
190
 
191
+ # @param source [String] a JSON string
192
+ # @return [Hash] the parsed JSON
134
193
  def parse_json(source)
135
194
  if defined?(::MultiJson)
136
195
  ::MultiJson.load(source, symbolize_keys: true)