fortnox-api 1.0.0.rc13 → 1.0.0.rc15
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 +4 -4
- data/CHANGELOG.md +91 -2
- data/README.md +52 -0
- data/fortnox.gemspec +2 -2
- data/lib/fortnox/attribute_keys.rb +37 -0
- data/lib/fortnox/collection.rb +1 -0
- data/lib/fortnox/error_translation.rb +22 -0
- data/lib/fortnox/mappers/struct.rb +5 -1
- data/lib/fortnox/rails.rb +47 -0
- data/lib/fortnox/resource.rb +17 -23
- data/lib/fortnox/serialisation.rb +68 -0
- data/lib/fortnox/struct.rb +88 -0
- data/lib/fortnox/structs/document_row.rb +1 -1
- data/lib/fortnox/types.rb +7 -1
- data/lib/fortnox/version.rb +1 -1
- data/lib/fortnox.rb +18 -0
- metadata +12 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ab7ad12aa41343980f10d5669c15e92c00ffe1b144f3e2d7eb250205c8f1ddaf
|
|
4
|
+
data.tar.gz: b88c8debe218e7e0bfcfe8f89ae3c14ac5eaf7f0c9466563c23dd7d13a153b22
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e3580f904dc0ab0b6ade2d540e858bd6e4b282c7589508c32431da156f12634365283ed28bd1e117d81f84f8081596612b9f6ae8736e8c8063b3d11b6f2b653
|
|
7
|
+
data.tar.gz: b9150f40236d1f2c22d3410f51e774b424af4612f905d3e63174c1f333b4b5b85b474648e0ac3e6999e2c243cc1994b71bd5b89c02146769644a0099b30f887f
|
data/CHANGELOG.md
CHANGED
|
@@ -8,11 +8,98 @@ and this project adheres to
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.0.0.rc15] - 2026-08-18
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **Cached resources no longer raise on the first cache hit.** The
|
|
16
|
+
`rest-easy` dependency is now `~> 1.4.1`, which stops `RestEasy::Meta`
|
|
17
|
+
claiming to implement methods it has no value for. Because it claimed
|
|
18
|
+
`marshal_dump`, marshalling a parsed resource silently wrote `nil` where
|
|
19
|
+
the meta state should be, and reading it back raised `NoMethodError:
|
|
20
|
+
undefined method '[]' for nil:NilClass`. Every parsed Fortnox resource
|
|
21
|
+
carries a `meta` — it is where the `partial` flag lives — so any consumer
|
|
22
|
+
caching resources in a store that marshals its entries (Rails'
|
|
23
|
+
`:memory_store` and `:file_store` among them) wrote an unusable entry on
|
|
24
|
+
the first request and raised on every cache hit after that.
|
|
25
|
+
|
|
26
|
+
**Upgrading with a warm cache:** entries written before this release are
|
|
27
|
+
not recoverable and now fail with `TypeError: instance of RestEasy::Meta
|
|
28
|
+
needs to have method 'marshal_load'`. Flush the cache or bump its key
|
|
29
|
+
namespace when upgrading. A process-local `:memory_store` clears itself on
|
|
30
|
+
restart and needs nothing.
|
|
31
|
+
|
|
32
|
+
## [1.0.0.rc14] - 2026-08-11
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- The `base64` runtime dependency is now bounded to `~> 0.2`, matching what
|
|
37
|
+
`rest-easy` already requires. It was declared open-ended when it was added
|
|
38
|
+
in 1.0.0.rc7, so a future `base64` 1.0 with a breaking change would have
|
|
39
|
+
been resolved into consumers' bundles unannounced.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- **Breaking** `Fortnox::UnknownAttributeError`, raised when `new`, `stub` or
|
|
44
|
+
`update` is passed an attribute the resource or struct doesn't declare.
|
|
45
|
+
Previously the attribute was silently discarded: the request went out
|
|
46
|
+
without it and Fortnox accepted the result, so a typo cost a field with no
|
|
47
|
+
indication anything was wrong. Worst on nested rows, where
|
|
48
|
+
`stub(order_rows: [{ artcile_number: '1' }])` sent `"OrderRows":[{}]` and
|
|
49
|
+
created an order with empty rows. Subclasses `Fortnox::AttributeError` and
|
|
50
|
+
carries `.attribute_names` (all of them) and `.attribute_name` (the first).
|
|
51
|
+
Consumers passing a wider hash than the resource declares must slice it
|
|
52
|
+
first. Parsing an API response is unaffected and stays tolerant of fields
|
|
53
|
+
the gem doesn't declare — Fortnox adds them over time, and a response must
|
|
54
|
+
not fail to parse because of one.
|
|
55
|
+
- Attribute hashes now accept string keys as well as symbols, on `new`,
|
|
56
|
+
`stub`, `update` and struct constructors. Previously a string key matched
|
|
57
|
+
nothing and was dropped, which is how a Rails controller passing `params`
|
|
58
|
+
through produced records missing every field.
|
|
59
|
+
- Optional Rails integration, `require 'fortnox/rails'`. Defines `as_json` on
|
|
60
|
+
resources, collections and nested structs so `render json:` works when they
|
|
61
|
+
are nested inside another structure — ActiveSupport walks nested objects
|
|
62
|
+
with `as_json`, and without a definition falls through to `Object#as_json`,
|
|
63
|
+
which serialises instance variables and leaks `api_data`,
|
|
64
|
+
`model_attributes`, `changes` and `meta` into response bodies. The file is
|
|
65
|
+
not loaded with the rest of the gem and ActiveSupport is not a runtime
|
|
66
|
+
dependency; requiring it is opt-in for apps that already have Rails.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- `to_json` on a resource holding nested structs no longer renders them
|
|
71
|
+
through `to_s`. An invoice or order with rows serialised them as
|
|
72
|
+
`"#<Fortnox::Structs::InvoiceRow:0x…>"`. `Fortnox::Struct#to_json` and
|
|
73
|
+
`Fortnox::Collection#to_json` are defined for the same reason — both
|
|
74
|
+
previously fell back to the default `Object#to_json`, producing an
|
|
75
|
+
inspect string rather than JSON. All three render the model representation
|
|
76
|
+
(snake_case attribute names); `to_api` still produces the Fortnox wire
|
|
77
|
+
format. This affects all consumers, not only Rails apps.
|
|
78
|
+
- Nested structs (`Fortnox::Structs::*`) now coerce boolean attributes from
|
|
79
|
+
the string spellings params arrive as (`'true'`, `'false'`, `'yes'`,
|
|
80
|
+
`'no'`, `'1'`, `'0'`, `'on'`, `'off'`), matching what resource attributes
|
|
81
|
+
have always accepted. Previously the struct-level type was strict and
|
|
82
|
+
rejected every string, so `Order.stub(order_rows: [{ housework: 'true' }])`
|
|
83
|
+
failed while the equivalent value on a resource attribute coerced cleanly
|
|
84
|
+
— callers passing Rails controller params had to cast booleans by hand
|
|
85
|
+
before building rows. Affects `DocumentRow#housework` and the `InvoiceRow`
|
|
86
|
+
and `OrderRow` subclasses. This asymmetry was introduced in 1.0.0.rc1 and
|
|
87
|
+
did not exist in 0.x.
|
|
88
|
+
- Struct construction now raises Fortnox-namespaced errors. A bad value in a
|
|
89
|
+
nested struct — reached directly via `Fortnox::Structs::OrderRow.new` or
|
|
90
|
+
indirectly via `stub`/`update` with a nested hash — previously raised
|
|
91
|
+
`Dry::Struct::Error`, which is a `TypeError` outside the `Fortnox::Error`
|
|
92
|
+
hierarchy, so `rescue Fortnox::AttributeError` blocks didn't catch it. It
|
|
93
|
+
now raises `Fortnox::ConstraintError` carrying `.attribute_name` and
|
|
94
|
+
`.value`, with the same message format as the resource-level error. This
|
|
95
|
+
extends the error translation added in 1.0.0.rc13, which covered resources
|
|
96
|
+
but not structs.
|
|
97
|
+
|
|
11
98
|
## [1.0.0.rc13] - 2026-08-04
|
|
12
99
|
|
|
13
100
|
### Changed
|
|
14
101
|
|
|
15
|
-
- String attributes now normalise `''` to `nil` at the type level. This
|
|
102
|
+
- **Breaking** String attributes now normalise `''` to `nil` at the type level. This
|
|
16
103
|
is the mechanism behind the reset fix below: Fortnox silently ignores
|
|
17
104
|
empty strings in update payloads — updating an attribute to `''` kept
|
|
18
105
|
the original value — and a field can only be cleared with an explicit
|
|
@@ -332,7 +419,9 @@ for the full list of breaking changes.
|
|
|
332
419
|
For changes prior to the 1.0 rewrite, see the
|
|
333
420
|
[0.x changelog](https://github.com/accodeing/fortnox-api/blob/v0.9.2/CHANGELOG.md).
|
|
334
421
|
|
|
335
|
-
[Unreleased]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.
|
|
422
|
+
[Unreleased]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.rc15...HEAD
|
|
423
|
+
[1.0.0.rc15]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.rc14...v1.0.0.rc15
|
|
424
|
+
[1.0.0.rc14]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.rc13...v1.0.0.rc14
|
|
336
425
|
[1.0.0.rc13]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.rc12...v1.0.0.rc13
|
|
337
426
|
[1.0.0.rc12]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.rc11...v1.0.0.rc12
|
|
338
427
|
[1.0.0.rc11]: https://github.com/accodeing/fortnox-api/compare/v1.0.0.rc10...v1.0.0.rc11
|
data/README.md
CHANGED
|
@@ -397,6 +397,58 @@ hitting some other schema-drift issue — please open an issue. There's a
|
|
|
397
397
|
maintainer-only knob to surface those warnings; see the
|
|
398
398
|
[Developer readme](DEVELOPER_README.md) for details.
|
|
399
399
|
|
|
400
|
+
### Rails
|
|
401
|
+
|
|
402
|
+
Two things are worth setting up before you render or receive Fortnox data in
|
|
403
|
+
a Rails app.
|
|
404
|
+
|
|
405
|
+
**Rendering.** Require the Rails integration once, in an initializer:
|
|
406
|
+
|
|
407
|
+
```ruby
|
|
408
|
+
# config/initializers/fortnox.rb
|
|
409
|
+
require 'fortnox/rails'
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Without it, `render json:` only works for a resource passed on its own.
|
|
413
|
+
Rendering one *inside* another structure walks it with `as_json`, which the
|
|
414
|
+
gem's types don't define outside this file, so ActiveSupport falls back to
|
|
415
|
+
serialising instance variables and your response body gets the gem's
|
|
416
|
+
internals:
|
|
417
|
+
|
|
418
|
+
```ruby
|
|
419
|
+
# Without fortnox/rails
|
|
420
|
+
render json: { invoices: [Fortnox::Invoice.find(1)] }
|
|
421
|
+
# => {"invoices":[{"api_data":{…},"model_attributes":{…},"changes":[…],"meta":{…}}]}
|
|
422
|
+
|
|
423
|
+
# With it
|
|
424
|
+
# => {"invoices":[{"document_number":1,"invoice_rows":[{"article_number":"101"}],…}]}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
It covers resources, collections, and nested structs, and produces the same
|
|
428
|
+
representation as `to_json` — model attribute names, not the Fortnox wire
|
|
429
|
+
names. Rendering a collection yields its records, as an array would;
|
|
430
|
+
pagination stays on the object (`.total`, `.pages`, `.current_page`) for you
|
|
431
|
+
to render alongside if you want it.
|
|
432
|
+
|
|
433
|
+
The file is not loaded with the rest of the gem and ActiveSupport is not a
|
|
434
|
+
dependency of this gem — requiring it is opt-in, for apps that already have
|
|
435
|
+
Rails.
|
|
436
|
+
|
|
437
|
+
**Receiving params.** Controller params can be passed through as they arrive.
|
|
438
|
+
String keys are accepted, and string values coerce — booleans from
|
|
439
|
+
`'true'`/`'false'`/`'1'`/`'0'`/`'yes'`/`'no'`/`'on'`/`'off'`, numbers from
|
|
440
|
+
their string forms:
|
|
441
|
+
|
|
442
|
+
```ruby
|
|
443
|
+
Fortnox::Order.stub('customer_number' => '1', 'order_rows' => [{ 'article_number' => '101' }])
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
An attribute the resource or struct doesn't declare raises
|
|
447
|
+
`Fortnox::UnknownAttributeError` rather than being dropped, so a typo or a
|
|
448
|
+
stray param surfaces at the call site instead of producing a record that is
|
|
449
|
+
quietly missing a field. Filter params to the attributes you mean to accept,
|
|
450
|
+
as you would for a model.
|
|
451
|
+
|
|
400
452
|
### Gotchas
|
|
401
453
|
|
|
402
454
|
See [docs/gotchas.md](docs/gotchas.md) for known quirks and edge cases in the
|
data/fortnox.gemspec
CHANGED
|
@@ -21,10 +21,10 @@ Gem::Specification.new do |spec|
|
|
|
21
21
|
|
|
22
22
|
spec.required_ruby_version = '>= 3.2.0'
|
|
23
23
|
|
|
24
|
-
spec.add_dependency 'base64'
|
|
24
|
+
spec.add_dependency 'base64', '~> 0.2'
|
|
25
25
|
spec.add_dependency 'countries', '~> 7.1'
|
|
26
26
|
spec.add_dependency 'dry-struct', '~> 1.5'
|
|
27
|
-
spec.add_dependency 'rest-easy', '~> 1.4.
|
|
27
|
+
spec.add_dependency 'rest-easy', '~> 1.4.1'
|
|
28
28
|
|
|
29
29
|
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
30
30
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fortnox
|
|
4
|
+
# Checks the attribute names a caller passes to `new`, `stub` and `update`.
|
|
5
|
+
#
|
|
6
|
+
# Both rest-easy and Dry::Struct build from the attributes they recognise and
|
|
7
|
+
# ignore everything else, so a typo or a string key used to vanish without a
|
|
8
|
+
# word: the request went out missing that field and Fortnox accepted it. The
|
|
9
|
+
# bigger the payload, the easier that is to miss.
|
|
10
|
+
#
|
|
11
|
+
# This only guards data a caller supplies. Parsing an API response stays
|
|
12
|
+
# tolerant of fields the gem doesn't declare — Fortnox adds them over time,
|
|
13
|
+
# and a response must not fail to parse because of one.
|
|
14
|
+
module AttributeKeys
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def check(data, known:, subject:)
|
|
18
|
+
normalised = normalise(data)
|
|
19
|
+
unknown = normalised.keys - known
|
|
20
|
+
raise Fortnox::UnknownAttributeError.new(unknown, subject) unless unknown.empty?
|
|
21
|
+
|
|
22
|
+
normalised
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Rails hands params through with string keys. Accept them rather than
|
|
26
|
+
# reporting every attribute in the hash as unknown.
|
|
27
|
+
def normalise(data)
|
|
28
|
+
data.to_h { |key, value| [symbolise(key), value] }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Left alone if it can't be a symbol, so it falls through to the unknown
|
|
32
|
+
# check and is reported as an attribute error rather than a NoMethodError.
|
|
33
|
+
def symbolise(key)
|
|
34
|
+
key.respond_to?(:to_sym) ? key.to_sym : key
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/fortnox/collection.rb
CHANGED
|
@@ -7,6 +7,7 @@ module Fortnox
|
|
|
7
7
|
# the pagination metadata Fortnox returns alongside collection responses.
|
|
8
8
|
class Collection
|
|
9
9
|
include Enumerable
|
|
10
|
+
include Serialisation::CollectionJSON
|
|
10
11
|
extend Forwardable
|
|
11
12
|
|
|
12
13
|
def_delegators :@items, :each, :first, :last, :size, :length, :empty?, :[], :to_a
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fortnox
|
|
4
|
+
# Translates rest-easy errors at the gem boundary so callers only see
|
|
5
|
+
# Fortnox-namespaced exceptions. Mixed into Fortnox::Resource both ways —
|
|
6
|
+
# `include` for the instance methods, `extend` for the class-level ones.
|
|
7
|
+
module ErrorTranslation
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def with_translated_errors
|
|
11
|
+
yield
|
|
12
|
+
rescue RestEasy::ConstraintError => e
|
|
13
|
+
raise Fortnox::ConstraintError.new(e.attribute_name, e.value, e.message)
|
|
14
|
+
rescue RestEasy::MissingAttributeError => e
|
|
15
|
+
raise Fortnox::MissingAttributeError, e.attribute_name
|
|
16
|
+
rescue RestEasy::AttributeError => e
|
|
17
|
+
raise Fortnox::AttributeError, e.message
|
|
18
|
+
rescue RestEasy::RequestError => e
|
|
19
|
+
raise Fortnox::RequestError, e.response || e.message
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -33,7 +33,11 @@ module Fortnox
|
|
|
33
33
|
attributes = data.transform_keys do |api_key|
|
|
34
34
|
api_to_model_map[api_key] || CONVENTION.parse(api_key)
|
|
35
35
|
end
|
|
36
|
-
|
|
36
|
+
# Drop fields the struct doesn't declare. Fortnox adds them over
|
|
37
|
+
# time, and a response must not fail to parse because of one — the
|
|
38
|
+
# strictness on `new` is aimed at what a caller passes, not at what
|
|
39
|
+
# the API returns.
|
|
40
|
+
struct_class.new(attributes.slice(*struct_class.attribute_names))
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
def serialise(struct)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fortnox'
|
|
4
|
+
require 'active_support/json'
|
|
5
|
+
|
|
6
|
+
# Optional Rails integration. Not loaded with the rest of the gem — require it
|
|
7
|
+
# explicitly, e.g. from config/initializers/fortnox.rb:
|
|
8
|
+
#
|
|
9
|
+
# require 'fortnox/rails'
|
|
10
|
+
#
|
|
11
|
+
# Rails serialises nested objects by calling `as_json` on them. Anything that
|
|
12
|
+
# doesn't define it falls through to `Object#as_json`, which dumps instance
|
|
13
|
+
# variables — so without this, `render json: { invoices: [invoice] }` puts the
|
|
14
|
+
# gem's internals (`api_data`, `model_attributes`, `changes`, `meta`) in the
|
|
15
|
+
# response body. `to_json` needs no help here; it is correct without Rails.
|
|
16
|
+
module Fortnox
|
|
17
|
+
# Deliberately not named `Rails`: inside `module Fortnox` that constant would
|
|
18
|
+
# shadow the framework's own for every other file in the gem.
|
|
19
|
+
module RailsSerialisation
|
|
20
|
+
# `:only` / `:except` are applied by Fortnox::Serialisation rather than by
|
|
21
|
+
# ActiveSupport, so they accept string and symbol names alike. The inner
|
|
22
|
+
# `as_json` is then called without options — it only needs to stringify
|
|
23
|
+
# keys and recurse into nested structs.
|
|
24
|
+
module ResourceMethods
|
|
25
|
+
# Same representation `to_json` produces: model attribute names.
|
|
26
|
+
def as_json(options = nil)
|
|
27
|
+
Serialisation.filter(model.attributes, options).as_json
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
module StructMethods
|
|
32
|
+
def as_json(options = nil)
|
|
33
|
+
Serialisation.filter(to_h, options).as_json
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
module CollectionMethods
|
|
38
|
+
def as_json(options = nil)
|
|
39
|
+
to_a.map { |item| item.as_json(options) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Fortnox::Resource.include(Fortnox::RailsSerialisation::ResourceMethods)
|
|
46
|
+
Fortnox::Struct.include(Fortnox::RailsSerialisation::StructMethods)
|
|
47
|
+
Fortnox::Collection.include(Fortnox::RailsSerialisation::CollectionMethods)
|
data/lib/fortnox/resource.rb
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Fortnox
|
|
4
|
+
# TODO: this class is still over the length limit even with error
|
|
5
|
+
# translation extracted — see todo.md.
|
|
6
|
+
# rubocop:disable Metrics/ClassLength
|
|
4
7
|
class Resource < RestEasy::Resource
|
|
5
8
|
include Fortnox::Types
|
|
9
|
+
include Serialisation::ResourceJSON
|
|
6
10
|
|
|
7
11
|
settings do
|
|
8
12
|
setting :instance_wrapper, reader: true
|
|
@@ -26,24 +30,6 @@ module Fortnox
|
|
|
26
30
|
|
|
27
31
|
after_serialise { |data| default_after_serialise(data) }
|
|
28
32
|
|
|
29
|
-
# Translate rest-easy errors at the gem boundary so callers only see
|
|
30
|
-
# Fortnox-namespaced exceptions.
|
|
31
|
-
module ErrorTranslation
|
|
32
|
-
private
|
|
33
|
-
|
|
34
|
-
def with_translated_errors
|
|
35
|
-
yield
|
|
36
|
-
rescue RestEasy::ConstraintError => e
|
|
37
|
-
raise Fortnox::ConstraintError.new(e.attribute_name, e.value, e.message)
|
|
38
|
-
rescue RestEasy::MissingAttributeError => e
|
|
39
|
-
raise Fortnox::MissingAttributeError, e.attribute_name
|
|
40
|
-
rescue RestEasy::AttributeError => e
|
|
41
|
-
raise Fortnox::AttributeError, e.message
|
|
42
|
-
rescue RestEasy::RequestError => e
|
|
43
|
-
raise Fortnox::RequestError, e.response || e.message
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
33
|
include ErrorTranslation # instance-level methods
|
|
48
34
|
extend ErrorTranslation # class-level methods
|
|
49
35
|
|
|
@@ -63,12 +49,12 @@ module Fortnox
|
|
|
63
49
|
end
|
|
64
50
|
end
|
|
65
51
|
|
|
66
|
-
def new(
|
|
67
|
-
with_translated_errors { super }
|
|
52
|
+
def new(model_data = {}, **kwargs)
|
|
53
|
+
with_translated_errors { super(checked_attributes(model_data.merge(kwargs))) }
|
|
68
54
|
end
|
|
69
55
|
|
|
70
56
|
def stub(**model_data)
|
|
71
|
-
with_translated_errors { super }
|
|
57
|
+
with_translated_errors { super(**checked_attributes(model_data)) }
|
|
72
58
|
end
|
|
73
59
|
|
|
74
60
|
def save(instance)
|
|
@@ -119,6 +105,13 @@ module Fortnox
|
|
|
119
105
|
|
|
120
106
|
private
|
|
121
107
|
|
|
108
|
+
# Attribute names a caller may pass. Read-only attributes are included:
|
|
109
|
+
# rest-easy drops them from the payload later, and rejecting them here
|
|
110
|
+
# would break round-tripping a parsed record back through `update`.
|
|
111
|
+
def checked_attributes(data)
|
|
112
|
+
AttributeKeys.check(data, known: all_attribute_definitions.keys, subject: self)
|
|
113
|
+
end
|
|
114
|
+
|
|
122
115
|
def extract_pagination(data)
|
|
123
116
|
return {} unless data.is_a?(Hash) && data.key?('MetaInformation')
|
|
124
117
|
|
|
@@ -131,8 +124,8 @@ module Fortnox
|
|
|
131
124
|
end
|
|
132
125
|
end
|
|
133
126
|
|
|
134
|
-
def update(
|
|
135
|
-
with_translated_errors { super }
|
|
127
|
+
def update(changes = {}, **kwargs)
|
|
128
|
+
with_translated_errors { super(self.class.send(:checked_attributes, changes.merge(kwargs))) }
|
|
136
129
|
end
|
|
137
130
|
|
|
138
131
|
def serialise(...)
|
|
@@ -159,4 +152,5 @@ module Fortnox
|
|
|
159
152
|
{ config.instance_wrapper => data }
|
|
160
153
|
end
|
|
161
154
|
end
|
|
155
|
+
# rubocop:enable Metrics/ClassLength
|
|
162
156
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fortnox
|
|
4
|
+
# JSON rendering for the three types a consumer can hold.
|
|
5
|
+
#
|
|
6
|
+
# rest-easy generates JSON straight from the attribute hash, and `JSON`
|
|
7
|
+
# renders anything it doesn't recognise through `to_s`. Nested structs are
|
|
8
|
+
# the case that matters: without this, an invoice with rows serialises them
|
|
9
|
+
# as `"#<Fortnox::Structs::InvoiceRow:0x…>"`.
|
|
10
|
+
#
|
|
11
|
+
# All three render the model representation — snake_case attribute names —
|
|
12
|
+
# not the Fortnox wire format. Use `to_api` for the latter.
|
|
13
|
+
module Serialisation
|
|
14
|
+
# Applies Rails' `:only` / `:except` render options.
|
|
15
|
+
#
|
|
16
|
+
# ActiveSupport's `Hash#as_json` slices a symbol-keyed hash, so a caller
|
|
17
|
+
# passing `only: ['name']` would match nothing and silently render `{}`.
|
|
18
|
+
# Normalise both sides instead, and accept either spelling.
|
|
19
|
+
#
|
|
20
|
+
# Anything that isn't an options hash is ignored — `JSON.generate` passes a
|
|
21
|
+
# `JSON::State` here when a resource is nested inside another structure.
|
|
22
|
+
def self.filter(attributes, options)
|
|
23
|
+
return attributes unless options.is_a?(::Hash)
|
|
24
|
+
|
|
25
|
+
if options[:only]
|
|
26
|
+
attributes.slice(*Array(options[:only]).map(&:to_sym))
|
|
27
|
+
elsif options[:except]
|
|
28
|
+
attributes.except(*Array(options[:except]).map(&:to_sym))
|
|
29
|
+
else
|
|
30
|
+
attributes
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Converts model values into JSON-safe primitives.
|
|
35
|
+
def self.json_safe(value)
|
|
36
|
+
case value
|
|
37
|
+
when Fortnox::Struct then json_safe(value.to_h)
|
|
38
|
+
when ::Hash then value.to_h { |key, nested| [key.to_s, json_safe(nested)] }
|
|
39
|
+
when ::Array then value.map { |element| json_safe(element) }
|
|
40
|
+
else value
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
module ResourceJSON
|
|
45
|
+
def to_json(options = nil)
|
|
46
|
+
::JSON.generate(Serialisation.json_safe(Serialisation.filter(model.attributes, options)))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
module StructJSON
|
|
51
|
+
def to_json(options = nil)
|
|
52
|
+
::JSON.generate(Serialisation.json_safe(Serialisation.filter(to_h, options)))
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
module CollectionJSON
|
|
57
|
+
# Renders as its records, the way an Array of them would. Pagination
|
|
58
|
+
# metadata stays addressable on the collection for callers that want to
|
|
59
|
+
# render it alongside.
|
|
60
|
+
def to_json(options = nil)
|
|
61
|
+
rendered = to_a.map do |item|
|
|
62
|
+
Serialisation.json_safe(Serialisation.filter(item.model.attributes, options))
|
|
63
|
+
end
|
|
64
|
+
::JSON.generate(rendered)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/fortnox/struct.rb
CHANGED
|
@@ -4,6 +4,24 @@ require 'dry-struct'
|
|
|
4
4
|
|
|
5
5
|
module Fortnox
|
|
6
6
|
class Struct < Dry::Struct
|
|
7
|
+
include Serialisation::StructJSON
|
|
8
|
+
|
|
9
|
+
# Accept the string keys Rails params arrive with. Not `&:to_sym` — a key
|
|
10
|
+
# that can't be symbolised would raise NoMethodError from outside the
|
|
11
|
+
# Fortnox::Error hierarchy instead of being reported as unknown.
|
|
12
|
+
transform_keys { |key| AttributeKeys.symbolise(key) }
|
|
13
|
+
|
|
14
|
+
# Reject attribute names the struct doesn't declare. Dry::Struct ignores
|
|
15
|
+
# them by default, so a misspelled key silently produced an empty row that
|
|
16
|
+
# Fortnox then accepted. Parsing stays tolerant of undeclared fields —
|
|
17
|
+
# Mappers::Struct slices API data to the declared attributes first.
|
|
18
|
+
schema schema.strict
|
|
19
|
+
|
|
20
|
+
# Lets us tell "no attributes given" apart from "given an empty hash", so
|
|
21
|
+
# Dry::Struct still gets to apply its own defaults in the former case.
|
|
22
|
+
NO_ATTRIBUTES = ::Object.new.freeze
|
|
23
|
+
private_constant :NO_ATTRIBUTES
|
|
24
|
+
|
|
7
25
|
class << self
|
|
8
26
|
def read_only_attributes
|
|
9
27
|
@read_only_attributes ||=
|
|
@@ -19,6 +37,76 @@ module Fortnox
|
|
|
19
37
|
read_only_attributes << name if flags.include?(:read_only)
|
|
20
38
|
super(name, type)
|
|
21
39
|
end
|
|
40
|
+
|
|
41
|
+
# Dry::Struct signals a bad value with Dry::Struct::Error, a TypeError
|
|
42
|
+
# that sits outside the Fortnox::Error hierarchy — so a caller's
|
|
43
|
+
# `rescue Fortnox::AttributeError` misses it and the failure surfaces as
|
|
44
|
+
# an unhandled crash. Structs are reachable through the same public
|
|
45
|
+
# entry points as resources (`Resource.stub` coerces nested hashes into
|
|
46
|
+
# structs), so they translate their errors the same way.
|
|
47
|
+
# rubocop:disable Style/OptionalBooleanParameter
|
|
48
|
+
# `safe` is Dry::Struct's own second positional parameter — it picks the
|
|
49
|
+
# non-raising coercion path — so it has to stay positional here. We only
|
|
50
|
+
# pass it through.
|
|
51
|
+
def new(attributes = NO_ATTRIBUTES, safe = false, &)
|
|
52
|
+
return super() if NO_ATTRIBUTES.equal?(attributes)
|
|
53
|
+
|
|
54
|
+
super
|
|
55
|
+
rescue Dry::Struct::Error => e
|
|
56
|
+
raise translated_error(e, attributes)
|
|
57
|
+
end
|
|
58
|
+
# rubocop:enable Style/OptionalBooleanParameter
|
|
59
|
+
|
|
60
|
+
# Attribute names this struct accepts.
|
|
61
|
+
def attribute_names
|
|
62
|
+
schema.keys.map(&:name)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def translated_error(error, attributes)
|
|
68
|
+
return Fortnox::AttributeError.new(error.message) unless attributes.is_a?(::Hash)
|
|
69
|
+
|
|
70
|
+
unknown_attribute_error(attributes) ||
|
|
71
|
+
failing_attribute_error(attributes) ||
|
|
72
|
+
Fortnox::AttributeError.new(error.message)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Dry::Struct reports a strict-schema rejection as "unexpected keys […]"
|
|
76
|
+
# in the message only. Recompute the names from the input rather than
|
|
77
|
+
# parsing that string back out.
|
|
78
|
+
def unknown_attribute_error(attributes)
|
|
79
|
+
unknown = AttributeKeys.normalise(attributes).keys - attribute_names
|
|
80
|
+
return nil if unknown.empty?
|
|
81
|
+
|
|
82
|
+
Fortnox::UnknownAttributeError.new(unknown, self)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Dry::Struct discards the underlying coercion error, keeping only its
|
|
86
|
+
# message, so re-run each value through its own key type to recover
|
|
87
|
+
# which attribute was at fault and what it was given.
|
|
88
|
+
def failing_attribute_error(attributes)
|
|
89
|
+
keys = schema.keys.to_h { |key| [key.name, key] }
|
|
90
|
+
|
|
91
|
+
attributes.each do |name, value|
|
|
92
|
+
key = keys[name.to_s.to_sym]
|
|
93
|
+
next if key.nil?
|
|
94
|
+
|
|
95
|
+
error = coercion_error_for(key, value)
|
|
96
|
+
return error if error
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def coercion_error_for(key, value)
|
|
103
|
+
key.call(value)
|
|
104
|
+
nil
|
|
105
|
+
rescue Dry::Types::CoercionError => e
|
|
106
|
+
# Report the key type's own message rather than Dry::Struct's wrapper,
|
|
107
|
+
# so struct and resource failures read identically.
|
|
108
|
+
Fortnox::ConstraintError.new(key.name, value, "Attribute '#{key.name}': #{e.message}")
|
|
109
|
+
end
|
|
22
110
|
end
|
|
23
111
|
end
|
|
24
112
|
end
|
|
@@ -34,7 +34,7 @@ module Fortnox
|
|
|
34
34
|
attribute? :discount_type, Types::DiscountTypes
|
|
35
35
|
|
|
36
36
|
# HouseWork If the row is housework
|
|
37
|
-
attribute? :housework, Types::
|
|
37
|
+
attribute? :housework, Types::CoercibleBool.optional
|
|
38
38
|
|
|
39
39
|
# HouseWorkHoursToReport Hours to be reported if the quantity of the row
|
|
40
40
|
# should not be used as hours. 5 digits
|
data/lib/fortnox/types.rb
CHANGED
|
@@ -7,7 +7,13 @@ module Fortnox
|
|
|
7
7
|
module Types
|
|
8
8
|
include Dry.Types()
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
# Booleans reach us as strings whenever the caller is a Rails app passing
|
|
11
|
+
# controller params through. Resource attributes already coerce those:
|
|
12
|
+
# rest-easy's `Boolean` is `Dry::Types['params.bool']`, and it wins over
|
|
13
|
+
# the strict type declared alongside it. Struct attributes have to ask for
|
|
14
|
+
# the same coercion explicitly, or the two paths disagree about whether
|
|
15
|
+
# 'true' is a boolean.
|
|
16
|
+
CoercibleBool = Types::Params::Bool
|
|
11
17
|
|
|
12
18
|
ArticleTypes = Types::Strict::String.enum(
|
|
13
19
|
'SERVICE', 'STOCK'
|
data/lib/fortnox/version.rb
CHANGED
data/lib/fortnox.rb
CHANGED
|
@@ -8,6 +8,9 @@ require 'zeitwerk'
|
|
|
8
8
|
module Fortnox
|
|
9
9
|
@loader = Zeitwerk::Loader.for_gem
|
|
10
10
|
@loader.collapse("#{__dir__}/fortnox/resources")
|
|
11
|
+
# Optional Rails integration — required explicitly by consumers, never
|
|
12
|
+
# autoloaded, and it defines no constant matching its path.
|
|
13
|
+
@loader.ignore("#{__dir__}/fortnox/rails.rb")
|
|
11
14
|
@loader.inflector.inflect(
|
|
12
15
|
'edi_information' => 'EDIInformation'
|
|
13
16
|
)
|
|
@@ -89,6 +92,21 @@ module Fortnox
|
|
|
89
92
|
end
|
|
90
93
|
end
|
|
91
94
|
|
|
95
|
+
class UnknownAttributeError < AttributeError
|
|
96
|
+
attr_reader :attribute_names, :subject
|
|
97
|
+
|
|
98
|
+
def initialize(attribute_names, subject = nil)
|
|
99
|
+
@attribute_names = Array(attribute_names)
|
|
100
|
+
@subject = subject
|
|
101
|
+
super("Unknown attribute#{'s' if @attribute_names.length > 1} for #{subject}: " \
|
|
102
|
+
"#{@attribute_names.map(&:inspect).join(', ')}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def attribute_name
|
|
106
|
+
@attribute_names.first
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
92
110
|
class MissingAccessToken < Error; end
|
|
93
111
|
|
|
94
112
|
OAUTH_TOKEN_URL = 'https://apps.fortnox.se/oauth-v1/token'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fortnox-api
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.0.
|
|
4
|
+
version: 1.0.0.rc15
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jonas Schubert Erlandsson
|
|
@@ -11,22 +11,22 @@ authors:
|
|
|
11
11
|
autorequire:
|
|
12
12
|
bindir: bin
|
|
13
13
|
cert_chain: []
|
|
14
|
-
date: 2026-08-
|
|
14
|
+
date: 2026-08-18 00:00:00.000000000 Z
|
|
15
15
|
dependencies:
|
|
16
16
|
- !ruby/object:Gem::Dependency
|
|
17
17
|
name: base64
|
|
18
18
|
requirement: !ruby/object:Gem::Requirement
|
|
19
19
|
requirements:
|
|
20
|
-
- - "
|
|
20
|
+
- - "~>"
|
|
21
21
|
- !ruby/object:Gem::Version
|
|
22
|
-
version: '0'
|
|
22
|
+
version: '0.2'
|
|
23
23
|
type: :runtime
|
|
24
24
|
prerelease: false
|
|
25
25
|
version_requirements: !ruby/object:Gem::Requirement
|
|
26
26
|
requirements:
|
|
27
|
-
- - "
|
|
27
|
+
- - "~>"
|
|
28
28
|
- !ruby/object:Gem::Version
|
|
29
|
-
version: '0'
|
|
29
|
+
version: '0.2'
|
|
30
30
|
- !ruby/object:Gem::Dependency
|
|
31
31
|
name: countries
|
|
32
32
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -61,14 +61,14 @@ dependencies:
|
|
|
61
61
|
requirements:
|
|
62
62
|
- - "~>"
|
|
63
63
|
- !ruby/object:Gem::Version
|
|
64
|
-
version: 1.4.
|
|
64
|
+
version: 1.4.1
|
|
65
65
|
type: :runtime
|
|
66
66
|
prerelease: false
|
|
67
67
|
version_requirements: !ruby/object:Gem::Requirement
|
|
68
68
|
requirements:
|
|
69
69
|
- - "~>"
|
|
70
70
|
- !ruby/object:Gem::Version
|
|
71
|
-
version: 1.4.
|
|
71
|
+
version: 1.4.1
|
|
72
72
|
description: Fortnox F3 REST API library, based on rest-easy.
|
|
73
73
|
email:
|
|
74
74
|
- info@accodeing.com
|
|
@@ -85,8 +85,10 @@ files:
|
|
|
85
85
|
- bin/fortnox-update-env
|
|
86
86
|
- fortnox.gemspec
|
|
87
87
|
- lib/fortnox.rb
|
|
88
|
+
- lib/fortnox/attribute_keys.rb
|
|
88
89
|
- lib/fortnox/auth/thread_local.rb
|
|
89
90
|
- lib/fortnox/collection.rb
|
|
91
|
+
- lib/fortnox/error_translation.rb
|
|
90
92
|
- lib/fortnox/mappers/country_code.rb
|
|
91
93
|
- lib/fortnox/mappers/date.rb
|
|
92
94
|
- lib/fortnox/mappers/document_row.rb
|
|
@@ -97,6 +99,7 @@ files:
|
|
|
97
99
|
- lib/fortnox/mappers/order_row.rb
|
|
98
100
|
- lib/fortnox/mappers/struct.rb
|
|
99
101
|
- lib/fortnox/mappers/struct_array.rb
|
|
102
|
+
- lib/fortnox/rails.rb
|
|
100
103
|
- lib/fortnox/resource.rb
|
|
101
104
|
- lib/fortnox/resources/article.rb
|
|
102
105
|
- lib/fortnox/resources/customer.rb
|
|
@@ -107,6 +110,7 @@ files:
|
|
|
107
110
|
- lib/fortnox/resources/project.rb
|
|
108
111
|
- lib/fortnox/resources/terms_of_payment.rb
|
|
109
112
|
- lib/fortnox/resources/unit.rb
|
|
113
|
+
- lib/fortnox/serialisation.rb
|
|
110
114
|
- lib/fortnox/struct.rb
|
|
111
115
|
- lib/fortnox/structs/default_delivery_types.rb
|
|
112
116
|
- lib/fortnox/structs/default_templates.rb
|