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
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)