camille 1.7.0 → 1.8.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7393e683a6c060b951c73b888a9e6723cf3333a6a2c1a338e525c95ba00050e
4
- data.tar.gz: 86bd555f6ffdce50614ed180043bc9de94476e2b803896a3d0e15b3a84f90576
3
+ metadata.gz: 43d1b00b15c6fb8dd557ad587bc0113f56328571ae309bf01e9f16a90cf6cc6e
4
+ data.tar.gz: f2f994348c8466586b6444885ea225446f15e45fac89f2942aa88ab135287195
5
5
  SHA512:
6
- metadata.gz: 3b9fa70ec2457f286d9627dc62f11d95d743f83b108f98b23f1f2446da9ca743216605f806fb129d12627792493b0a0328d6bcb582ecdb3ad24878bbcc58d8ad
7
- data.tar.gz: 7d8d08956a4f43cde2bf7efc94955626efd983abbf98e2d8ae4c39d20a049f7e2055e08a800c4c67154f55d733109e45acf48545c35aa18deb1fd53eab178d9b
6
+ metadata.gz: be0fba9d87641faa8df13542937860f4d04d2cbfb5ec65b7e3ace488aff7d373f6c83d5601fe1aa046e6d6b0df6ab565cc8a5fbe9669a4084613822a6af3c328
7
+ data.tar.gz: 426ab4e50a688e656552d512bed7b9f324bab52e23165d461abdba7843228a80ab9e2956d7232664a0e0b9f953061316104f4e59544dfdc569b4455e6c0e2efe
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.1
4
+
5
+ ### Fixed
6
+
7
+ * `Record`, `Pick` and `Omit` now implement `check_params`, so nested objects inside them get their camelCase keys converted instead of failing with errors like `Expected array, got nil`. Record keys are still not converted.
8
+ * `response.data` in `camille/testing` no longer type checks non-200 responses, matching `Camille::Controller#render`, which only checks and converts keys for 200 responses. Non-200 bodies are returned as-is.
9
+
10
+ ## 1.8.0
11
+
12
+ ### Added
13
+
14
+ * Added `render!` to every type (`Camille::Types::Product.render!(value)`), which checks a value and returns a `Camille::Rendered`, raising `Camille::BasicType::RenderError` on a type mismatch. A `Rendered` can be cached and placed into a later response, where the type accepts it by fingerprint without re-checking and its JSON is spliced verbatim. See "Caching rendered fragments" in the README.
15
+ * `Camille::Rendered` now marshals as `[fingerprint, json]`, so entries stored with the default `Rails.cache` coder stay valid across changes to the class.
16
+
17
+ ### Changed
18
+
19
+ * Renamed `Camille::Controller::InvalidRenderArgumentError` to `Camille::Controller::RenderArgumentError`, matching the naming of the other controller errors. The old name remains as an alias.
20
+
3
21
  ## 1.7.0
4
22
 
5
23
  ### Fixed
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- camille (1.7.0)
4
+ camille (1.8.1)
5
5
  rails (>= 6.1, < 8.1)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -259,6 +259,30 @@ object:
259
259
  array[2]: Expected number, got "3".
260
260
  ```
261
261
 
262
+ ### Caching rendered fragments
263
+
264
+ Type checking and key conversion run on every `render`. For data that is expensive to build and shared across requests, you can check it once and cache the result as a `Camille::Rendered`:
265
+
266
+ ```ruby
267
+ rendered = Camille::Types::Product.render!(serialize(product))
268
+ # => Camille::Rendered with the type's fingerprint and the final JSON string
269
+ Rails.cache.write("product/#{product.id}/#{Camille::Types::Product.new.fingerprint}", rendered)
270
+ ```
271
+
272
+ `render!` raises `Camille::BasicType::RenderError` if the value doesn't match the type. A `Rendered` can be placed anywhere a value of that type is expected:
273
+
274
+ ```ruby
275
+ render json: {
276
+ products: Rails.cache.read_multi(*keys).values # Product[]
277
+ }
278
+ ```
279
+
280
+ The type accepts it by comparing fingerprints instead of re-checking, and `to_json` splices the stored string verbatim, so no parsing or re-serialization happens on the cached fragments.
281
+
282
+ A `Rendered` is immutable and opaque. If a fragment needs to be modified before rendering, cache the plain hash instead and let Camille check it as usual; the two forms are a per-fragment choice. Keep volatile or per-user fields outside cached fragments, and include the type's `fingerprint` in the cache key so entries are invalidated when the type changes.
283
+
284
+ `Rendered` defines `marshal_dump`/`marshal_load` as `[fingerprint, json]`, so the default `Rails.cache` coder stores just the two strings. If you use the `:message_pack` cache serializer, register the class with `ActiveSupport::MessagePack::CacheSerializer` using the same pair.
285
+
262
286
  ### Reloading
263
287
 
264
288
  Everything in `config/camille/types` and `config/camille/schemas` will automatically reload after changes in development environment, just like other files in Rails.
@@ -280,7 +304,7 @@ get '/products/data'
280
304
  expect(response.data[:product][:available_stock]).to eq(1)
281
305
  ```
282
306
 
283
- If the response body fails the type check the helper raises `Camille::Testing::ResponseTypeError`. If the route has no Camille endpoint it raises `Camille::Testing::MissingEndpointError`.
307
+ Since `Camille::Controller#render` only type checks and converts keys for 200 responses, `response.data` returns non-200 bodies as-is without validation. If a 200 response body fails the type check the helper raises `Camille::Testing::ResponseTypeError`. If the route has no Camille endpoint it raises `Camille::Testing::MissingEndpointError`.
284
308
 
285
309
  ## Versioning
286
310
 
data/camille.gemspec ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/camille/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "camille"
7
+ spec.version = Camille::VERSION
8
+ spec.authors = ["merely"]
9
+ spec.email = ["git@merely.ca"]
10
+ spec.license = "MIT"
11
+
12
+ spec.summary = "Typed API schema for Rails with TypeScript codegen"
13
+ spec.description = ""
14
+ spec.homepage = "https://github.com/onyxblade/camille"
15
+ spec.required_ruby_version = ">= 2.6.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "https://github.com/onyxblade/camille"
19
+
20
+ # Specify which files should be added to the gem when it is released.
21
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
+ `git ls-files -z`.split("\x0").reject do |f|
24
+ (f == __FILE__) || f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
25
+ end
26
+ end
27
+ spec.bindir = "exe"
28
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
+ spec.require_paths = ["lib"]
30
+
31
+ # Uncomment to register a new dependency of your gem
32
+ spec.add_dependency "rails", ">= 6.1", "< 8.1"
33
+
34
+ # For more information and examples about making a new gem, checkout our
35
+ # guide at: https://bundler.io/guides/creating_gem.html
36
+ end
@@ -4,6 +4,7 @@ module Camille
4
4
  # This class specifies the methods available for all types includeing built-in and custom ones.
5
5
  class BasicType
6
6
  class InvalidTypeError < ::ArgumentError; end
7
+ class RenderError < ::StandardError; end
7
8
 
8
9
  module CheckRendered
9
10
  def check value
@@ -25,6 +26,19 @@ module Camille
25
26
  @fingerprint = Digest::MD5.hexdigest self.class.name
26
27
  end
27
28
 
29
+ # Checks `value` and returns a `Camille::Rendered`, raising `RenderError` with
30
+ # the printed type error if the check fails.
31
+ def render! value
32
+ result = check(value)
33
+ if result.type_error?
34
+ string_io = StringIO.new
35
+ Camille::TypeErrorPrinter.new(result).print(string_io)
36
+ raise RenderError.new("\nType check failed for render!.\n#{string_io.string}")
37
+ else
38
+ result.render
39
+ end
40
+ end
41
+
28
42
  def | other
29
43
  Camille::Types::Union.new(self, other)
30
44
  end
@@ -3,7 +3,8 @@ module Camille
3
3
  class TypeError < ::StandardError; end
4
4
  class ParamsTypeError < TypeError; end
5
5
  class ResponseTypeError < TypeError; end
6
- class InvalidRenderArgumentError < ::ArgumentError; end
6
+ class RenderArgumentError < ::ArgumentError; end
7
+ InvalidRenderArgumentError = RenderArgumentError
7
8
  class MissingRenderError < ::StandardError; end
8
9
 
9
10
  def camille_schema
@@ -31,7 +32,7 @@ module Camille
31
32
  super(json: rendered)
32
33
  end
33
34
  else
34
- raise InvalidRenderArgumentError.new("Expected key :json for `render` call.")
35
+ raise RenderArgumentError.new("Expected key :json for `render` call.")
35
36
  end
36
37
  else
37
38
  super
@@ -25,6 +25,10 @@ module Camille
25
25
  processed_object.check(value)
26
26
  end
27
27
 
28
+ def check_params value
29
+ processed_object.check_params(value)
30
+ end
31
+
28
32
  def literal
29
33
  "#{klass_name}<#{@type.literal}, #{keys_in_literal}>"
30
34
  end
@@ -1,4 +1,9 @@
1
1
  module Camille
2
+ # A JSON string that has already passed a type check for the type identified by
3
+ # `fingerprint`. Any type will accept a `Rendered` with a matching fingerprint
4
+ # without re-checking it, and `to_json` splices the string verbatim, so a
5
+ # `Rendered` can be stored (e.g. in `Rails.cache`) and placed into a later
6
+ # response as an opaque, immutable fragment.
2
7
  class Rendered
3
8
  attr_reader :fingerprint, :json
4
9
 
@@ -10,5 +15,16 @@ module Camille
10
15
  def to_json options = nil
11
16
  @json
12
17
  end
18
+
19
+ # Marshal (and therefore the default `Rails.cache` coder) stores only the two
20
+ # strings, so cached entries stay valid across changes to this class's
21
+ # internals.
22
+ def marshal_dump
23
+ [@fingerprint, @json]
24
+ end
25
+
26
+ def marshal_load array
27
+ @fingerprint, @json = array
28
+ end
13
29
  end
14
- end
30
+ end
@@ -24,6 +24,10 @@ module Camille
24
24
  "No camille endpoint for #{controller_class_name}##{action}."
25
25
  end
26
26
 
27
+ # Camille::Controller#render only type checks and converts keys for
28
+ # 200 responses, so mirror that here and return non-200 bodies as-is.
29
+ return deep_indifferent(parsed_body) unless status == 200
30
+
27
31
  result = endpoint.response_type.check_params(parsed_body)
28
32
  if result.type_error?
29
33
  io = StringIO.new
data/lib/camille/type.rb CHANGED
@@ -35,6 +35,10 @@ module Camille
35
35
  new.check_params value
36
36
  end
37
37
 
38
+ def self.render! value
39
+ new.render! value
40
+ end
41
+
38
42
  def self.klass_name
39
43
  name.gsub(/^Camille::Types::/, '')
40
44
  end
@@ -11,28 +11,14 @@ module Camille
11
11
  end
12
12
 
13
13
  def check value
14
- if value.is_a? ::Hash
15
-
16
- results = value.map.with_index do |(k, v), index|
17
- [index, check_pair(k, v)]
18
- end
19
-
20
- errors = results.map do |index, result|
21
- if result.instance_of?(Camille::TypeError)
22
- ["record[#{index}]", result]
23
- else
24
- nil
25
- end
26
- end.compact
14
+ check_with_method(value, :check)
15
+ end
27
16
 
28
- if errors.empty?
29
- Camille::Checked.new(fingerprint, results.map{|_, result| [result[0].value, result[1].value]}.to_h)
30
- else
31
- Camille::TypeError.new(**errors.to_h)
32
- end
33
- else
34
- Camille::TypeError.new("Expected hash, got #{value.inspect}.")
35
- end
17
+ # Record keys are dynamic, so they are never converted between
18
+ # camelCase and snake_case. Only the values are checked with
19
+ # `check_params` so nested objects get their keys converted.
20
+ def check_params value
21
+ check_with_method(value, :check_params)
36
22
  end
37
23
 
38
24
  def self.[] key, value
@@ -44,9 +30,34 @@ module Camille
44
30
  end
45
31
 
46
32
  private
47
- def check_pair key, value
33
+ def check_with_method value, method_name
34
+ if value.is_a? ::Hash
35
+
36
+ results = value.map.with_index do |(k, v), index|
37
+ [index, check_pair(k, v, method_name)]
38
+ end
39
+
40
+ errors = results.map do |index, result|
41
+ if result.instance_of?(Camille::TypeError)
42
+ ["record[#{index}]", result]
43
+ else
44
+ nil
45
+ end
46
+ end.compact
47
+
48
+ if errors.empty?
49
+ Camille::Checked.new(fingerprint, results.map{|_, result| [result[0].value, result[1].value]}.to_h)
50
+ else
51
+ Camille::TypeError.new(**errors.to_h)
52
+ end
53
+ else
54
+ Camille::TypeError.new("Expected hash, got #{value.inspect}.")
55
+ end
56
+ end
57
+
58
+ def check_pair key, value, method_name
48
59
  key_result = @key.check key
49
- value_result = @value.check value
60
+ value_result = @value.public_send(method_name, value)
50
61
 
51
62
  if key_result.checked? && value_result.checked?
52
63
  [key_result, value_result]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Camille
4
- VERSION = "1.7.0"
4
+ VERSION = "1.8.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: camille
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - merely
@@ -48,6 +48,7 @@ files:
48
48
  - benchmarks/returning_multiple_values.rb
49
49
  - bin/console
50
50
  - bin/setup
51
+ - camille.gemspec
51
52
  - gemfiles/rails-7.2
52
53
  - gemfiles/rails-8.0
53
54
  - lib/camille.rb