sequel-notion 0.2.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +62 -4
- data/lib/sequel/adapters/notion.rb +48 -14
- data/lib/sequel/notion/file_uploads.rb +59 -0
- data/lib/sequel/notion/notion_file.rb +12 -0
- data/lib/sequel/notion/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c1fb73a833c0fc96c70528e504873f4765efec4ccddc4737f6eedc8df3723e60
|
|
4
|
+
data.tar.gz: 0e2feb6f99c68194d3e7964d3ae16c36c63887ca2064a299b2ba46469cf5da8f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d280403f620b4c64ba88a46e2b18963c175464c0545d27026a9dfe05a20b4dd527bf8368d25386a5b4cb409fa4bbaffbc8cb0a6fb3e14c9101a1d33719c0c8cf
|
|
7
|
+
data.tar.gz: f2113de1c055a39cc0f77755447c520a36cb615e9e5bc9a1652a9d6c0394fd7df5e3da99d3971d4a85a094385626b0fe893a6429212e7dee54ced3b785d0e3de
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1 — 2026-10-22
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `DB.upload_file(data, filename:, content_type:)` uploads a file and
|
|
8
|
+
returns its id; `File.uploaded(id, name:)` writes it to a files
|
|
9
|
+
property (checked live).
|
|
10
|
+
- `DB.request` is documented as the way to reach endpoints the adapter
|
|
11
|
+
does not map (blocks, comments), with its retries and errors.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Failed connections (refused, reset, unresolved, connect timeout) are
|
|
16
|
+
retried like timeouts; page creation and a file upload's send only
|
|
17
|
+
when the connection never opened.
|
|
18
|
+
|
|
3
19
|
## 0.2.0 — 2026-10-10
|
|
4
20
|
|
|
5
21
|
### Added
|
data/README.md
CHANGED
|
@@ -22,6 +22,7 @@ else raises a `Sequel::Error` rather than be dropped.
|
|
|
22
22
|
- [Writing](#writing)
|
|
23
23
|
- [Models](#models)
|
|
24
24
|
- [Schema](#schema)
|
|
25
|
+
- [Raw requests](#raw-requests)
|
|
25
26
|
- [Errors, retries and logging](#errors-retries-and-logging)
|
|
26
27
|
- [Known shortfalls](#known-shortfalls)
|
|
27
28
|
- [Checked against the live API](#checked-against-the-live-api)
|
|
@@ -339,6 +340,27 @@ writable columns in schema order.
|
|
|
339
340
|
`update` and `delete` first collect the matching page ids, then send one
|
|
340
341
|
request per page.
|
|
341
342
|
|
|
343
|
+
### Uploading files
|
|
344
|
+
|
|
345
|
+
`DB.upload_file` uploads bytes to Notion and returns the file upload's
|
|
346
|
+
id; `File.uploaded` turns that id into a value for a files property:
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
id = DB.upload_file(File.binread("q.pdf"), filename: "q.pdf",
|
|
350
|
+
content_type: "application/pdf")
|
|
351
|
+
DB[:leases].where(id: page).update(Docs: Sequel::Notion::File.uploaded(id))
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
It takes the file's bytes, as a `String` or anything with `#read` (a
|
|
355
|
+
`String` is the content, never a path: `upload_file("q.pdf", …)`
|
|
356
|
+
uploads the five bytes `q.pdf`), and sends them in one part
|
|
357
|
+
(Notion's `single_part` mode; `multi_part` is not supported). The upload
|
|
358
|
+
reads back as a Notion-hosted file. Notion keeps the upload's file name
|
|
359
|
+
in ASCII only (`reçu été "q".pdf` became `reu_t_q.pdf`);
|
|
360
|
+
`File.uploaded(id, name: "reçu.pdf")` names the file as given. An upload
|
|
361
|
+
that is never attached carries an `expiry_time` an hour away. The id can also go into a file block
|
|
362
|
+
through [raw requests](#raw-requests).
|
|
363
|
+
|
|
342
364
|
|
|
343
365
|
## Models
|
|
344
366
|
|
|
@@ -385,15 +407,47 @@ in Notion; every name of that data source (an alias, its id) sees the
|
|
|
385
407
|
change.
|
|
386
408
|
|
|
387
409
|
|
|
410
|
+
## Raw requests
|
|
411
|
+
|
|
412
|
+
`DB.request(verb, path, data = nil)` sends one request to any JSON
|
|
413
|
+
endpoint the adapter does not cover (blocks, comments, users), through
|
|
414
|
+
the same connection, retries, errors and logging as every query:
|
|
415
|
+
|
|
416
|
+
```ruby
|
|
417
|
+
DB.request(:get, "blocks/#{page}/children", page_size: 100)
|
|
418
|
+
DB.request(:patch, "blocks/#{page}/children", children: [block])
|
|
419
|
+
DB.request(:delete, "blocks/#{block_id}")
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
- `path` is relative to `https://api.notion.com/v1`; the version sent is
|
|
423
|
+
`Sequel::Notion::API_VERSION`.
|
|
424
|
+
- `data` is the query string for `:get` and `:delete`, and the JSON body
|
|
425
|
+
for `:post` and `:patch`.
|
|
426
|
+
- It returns the parsed JSON body, a `Hash`; a response that is not
|
|
427
|
+
typed JSON comes back as its `String`.
|
|
428
|
+
- Errors and retries are those of the next section. Every POST but page
|
|
429
|
+
creation and a file upload's send is retried, so a POST sent this way
|
|
430
|
+
must be safe to repeat; so must a PATCH, retried after a timeout.
|
|
431
|
+
- It spends no `max_requests` budget, unless called inside a query that
|
|
432
|
+
set one.
|
|
433
|
+
- It logs `VERB path` with `data`, so `data` should hold neither secrets
|
|
434
|
+
nor bulk bytes.
|
|
435
|
+
|
|
436
|
+
It sends JSON only: a file goes through [`upload_file`](#uploading-files).
|
|
437
|
+
|
|
438
|
+
|
|
388
439
|
## Errors, retries and logging
|
|
389
440
|
|
|
390
|
-
- A failed request, or a response that
|
|
441
|
+
- A failed request, or a response typed JSON that does not parse, raises
|
|
391
442
|
`Sequel::DatabaseError`, with Notion's error code and message in the
|
|
392
443
|
text. A 404 raises its subclass `Sequel::Notion::NotFoundError`
|
|
393
444
|
(`where(id:)` turns it into no row).
|
|
394
445
|
- Responses 429, 502, 503 and 504 are retried up to four times, honouring
|
|
395
|
-
`Retry-After`.
|
|
396
|
-
|
|
446
|
+
`Retry-After`. So are timeouts and failed connections (refused, reset,
|
|
447
|
+
unresolved). Page creation and a file upload's send are retried only
|
|
448
|
+
after a 429 or when the connection never opened, so a timeout or a
|
|
449
|
+
reset cannot create a page twice, nor send a file Notion already holds
|
|
450
|
+
(it refuses a second send, checked live).
|
|
397
451
|
- Requests go to the database's loggers (`DB.loggers << Logger.new($stdout)`)
|
|
398
452
|
as `POST data_sources/…/query` with their body.
|
|
399
453
|
|
|
@@ -443,7 +497,11 @@ believes Notion accepts. These were also checked against
|
|
|
443
497
|
- **Rollups:** a `show_original` rollup through 30 relations read in
|
|
444
498
|
full, all 30 values, in a query and by id.
|
|
445
499
|
- **Files:** a Notion-hosted file read from a page and written back
|
|
446
|
-
unchanged, signed URL included, kept by Notion.
|
|
500
|
+
unchanged, signed URL included, kept by Notion. `upload_file` with
|
|
501
|
+
binary bytes and a non-ASCII name, attached through `File.uploaded`
|
|
502
|
+
with and without `name:`, read back and downloaded byte for byte; a
|
|
503
|
+
second send to the same upload refused; an unsent upload's
|
|
504
|
+
`expiry_time` an hour after its creation (2026-10-22).
|
|
447
505
|
- **Pages:** lookups by id with or without dashes; creation with the
|
|
448
506
|
`data_source_id` parent; writing, reading back and clearing every
|
|
449
507
|
writable type; trashing and restoring; a relation of 26 pages read in
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "net/http"
|
|
4
5
|
|
|
5
6
|
require "faraday"
|
|
6
7
|
require "faraday/retry"
|
|
@@ -8,6 +9,7 @@ require "sequel"
|
|
|
8
9
|
|
|
9
10
|
require "sequel/notion/dataset"
|
|
10
11
|
require "sequel/notion/errors"
|
|
12
|
+
require "sequel/notion/file_uploads"
|
|
11
13
|
require "sequel/notion/model_support"
|
|
12
14
|
require "sequel/notion/page_api"
|
|
13
15
|
require "sequel/notion/registry"
|
|
@@ -26,15 +28,40 @@ module Sequel
|
|
|
26
28
|
# faraday-retry honours.
|
|
27
29
|
RETRY_STATUSES = [429, 502, 503, 504].freeze
|
|
28
30
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
# Exceptions worth another attempt: faraday-retry's timeouts, plus
|
|
32
|
+
# a failed connection (refused, reset, unresolved), which is not
|
|
33
|
+
# among them.
|
|
34
|
+
RETRY_EXCEPTIONS = [*Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS,
|
|
35
|
+
Faraday::ConnectionFailed].freeze
|
|
36
|
+
|
|
37
|
+
# Causes of a ConnectionFailed raised before the request left: no
|
|
38
|
+
# connection was ever opened, so nothing reached Notion.
|
|
39
|
+
UNSENT_ERRORS = [Errno::ECONNREFUSED, Errno::EHOSTUNREACH,
|
|
40
|
+
Errno::ENETUNREACH, SocketError,
|
|
41
|
+
Net::OpenTimeout].freeze
|
|
42
|
+
|
|
43
|
+
# The POSTs a repeat could duplicate or break: creating a page, and
|
|
44
|
+
# sending a file, which Notion refuses once the upload holds one.
|
|
45
|
+
UNREPEATABLE = %r{/(?:pages|file_uploads/[^/]+/send)\z}
|
|
46
|
+
|
|
47
|
+
# Retry an unrepeatable POST only when Notion refused it outright
|
|
48
|
+
# (rate limited) or when it was never sent.
|
|
49
|
+
RETRY_IF = lambda do |env, exception|
|
|
50
|
+
!(env.method == :post && env.url.path.match?(UNREPEATABLE)) ||
|
|
51
|
+
env.status == 429 || UNSENT.call(exception)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Did the exception prove nothing was sent? An adapter may raise a
|
|
55
|
+
# bare timeout, which wraps nothing.
|
|
56
|
+
UNSENT = lambda do |exception|
|
|
57
|
+
cause = exception.is_a?(Faraday::Error) &&
|
|
58
|
+
exception.wrapped_exception
|
|
59
|
+
UNSENT_ERRORS.any? { cause.is_a?(it) }
|
|
34
60
|
end
|
|
35
61
|
|
|
36
62
|
class Database < Sequel::Database
|
|
37
63
|
include PageApi
|
|
64
|
+
include FileUploads
|
|
38
65
|
include Registry
|
|
39
66
|
include RequestBudget
|
|
40
67
|
include SchemaLookup
|
|
@@ -88,16 +115,9 @@ module Sequel
|
|
|
88
115
|
# HTTP failure re-raised as a Sequel::DatabaseError. Only its
|
|
89
116
|
# 200 spends the query's budget, after the retries.
|
|
90
117
|
def request(verb, path, body = nil)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
log_connection_yield("#{verb.upcase} #{path}", conn,
|
|
94
|
-
body && [body]) do
|
|
95
|
-
conn.public_send(verb, path, body).body
|
|
96
|
-
end
|
|
97
|
-
end
|
|
118
|
+
notion_call(verb, path, body && [body]) do |conn|
|
|
119
|
+
conn.public_send(verb, path, body).body
|
|
98
120
|
end
|
|
99
|
-
rescue Faraday::Error => e
|
|
100
|
-
raise database_error(e)
|
|
101
121
|
end
|
|
102
122
|
|
|
103
123
|
# Where Sequel sends SQL to run: run, <<, truncate, the
|
|
@@ -108,6 +128,19 @@ module Sequel
|
|
|
108
128
|
|
|
109
129
|
private
|
|
110
130
|
|
|
131
|
+
# Run the block with a pooled connection, under the budget,
|
|
132
|
+
# logged as "VERB path" with +logged+
|
|
133
|
+
def notion_call(verb, path, logged)
|
|
134
|
+
within_budget do
|
|
135
|
+
synchronize do |conn|
|
|
136
|
+
log_connection_yield("#{verb.upcase} #{path}", conn,
|
|
137
|
+
logged) { yield conn }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
rescue Faraday::Error => e
|
|
141
|
+
raise database_error(e)
|
|
142
|
+
end
|
|
143
|
+
|
|
111
144
|
# A number column is :float, and Sequel's model typecast would
|
|
112
145
|
# turn 5 into 5.0; Notion keeps, and reads back, an Integer
|
|
113
146
|
def typecast_value_float(value)
|
|
@@ -138,6 +171,7 @@ module Sequel
|
|
|
138
171
|
interval: 0.5,
|
|
139
172
|
backoff_factor: 2,
|
|
140
173
|
methods: %i[get patch delete],
|
|
174
|
+
exceptions: RETRY_EXCEPTIONS,
|
|
141
175
|
retry_if: RETRY_IF,
|
|
142
176
|
retry_statuses: RETRY_STATUSES }
|
|
143
177
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Sequel
|
|
6
|
+
module Notion
|
|
7
|
+
# Notion's single-part file upload: create the upload, send its
|
|
8
|
+
# bytes as multipart/form-data, and hand back the id that a files
|
|
9
|
+
# property (File.uploaded) or a file block attaches.
|
|
10
|
+
module FileUploads
|
|
11
|
+
# Upload +data+, the file's bytes (a String, never a path, or
|
|
12
|
+
# anything with #read), as one part, and return the file
|
|
13
|
+
# upload's id. Notion keeps an upload for an hour unless it is
|
|
14
|
+
# attached.
|
|
15
|
+
#
|
|
16
|
+
# id = DB.upload_file(File.binread("q.pdf"),
|
|
17
|
+
# filename: "q.pdf",
|
|
18
|
+
# content_type: "application/pdf")
|
|
19
|
+
# DB[:leases].where(id: page).update(Docs: File.uploaded(id))
|
|
20
|
+
#
|
|
21
|
+
def upload_file(data, filename:, content_type:)
|
|
22
|
+
bytes = data.respond_to?(:read) ? data.read : data.to_str
|
|
23
|
+
upload = request(:post, "file_uploads",
|
|
24
|
+
mode: "single_part", filename: filename,
|
|
25
|
+
content_type: content_type)
|
|
26
|
+
send_file_upload(upload.fetch("id"), bytes,
|
|
27
|
+
filename, content_type)
|
|
28
|
+
upload["id"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# The bytes go out as a String body under their own content
|
|
34
|
+
# type, which the :json middleware leaves alone; the log names
|
|
35
|
+
# the file rather than inspect it.
|
|
36
|
+
def send_file_upload(id, bytes, filename, content_type)
|
|
37
|
+
path = "file_uploads/#{id}/send"
|
|
38
|
+
body, type = multipart_body(bytes, filename, content_type)
|
|
39
|
+
logged = "#{filename} (#{bytes.bytesize} bytes)"
|
|
40
|
+
notion_call(:post, path, [logged]) do |conn|
|
|
41
|
+
conn.post(path, body, "Content-Type" => type).body
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# One "file" part, as a browser encodes a form: a quote, CR
|
|
46
|
+
# or LF in the file name is percent-encoded.
|
|
47
|
+
def multipart_body(bytes, filename, content_type)
|
|
48
|
+
boundary = "sequel-notion-#{SecureRandom.hex(16)}"
|
|
49
|
+
name = filename.gsub(/["\r\n]/) { format("%%%02X", it.ord) }
|
|
50
|
+
head = "--#{boundary}\r\n" \
|
|
51
|
+
"Content-Disposition: form-data; name=\"file\"; " \
|
|
52
|
+
"filename=\"#{name}\"\r\n" \
|
|
53
|
+
"Content-Type: #{content_type}\r\n\r\n"
|
|
54
|
+
body = head.b + bytes.b + "\r\n--#{boundary}--\r\n".b
|
|
55
|
+
[body, "multipart/form-data; boundary=#{boundary}"]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -151,6 +151,18 @@ module Sequel
|
|
|
151
151
|
new(url: url, type: :external, name: name)
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
+
# A file uploaded with Database#upload_file, to write to a
|
|
155
|
+
# files property; it reads back as a Notion-hosted file
|
|
156
|
+
#
|
|
157
|
+
# File.uploaded(DB.upload_file(io, filename: "q.pdf", …))
|
|
158
|
+
#
|
|
159
|
+
def self.uploaded(id, name: nil)
|
|
160
|
+
raw = { "type" => "file_upload",
|
|
161
|
+
"file_upload" => { "id" => id } }
|
|
162
|
+
raw["name"] = name if name
|
|
163
|
+
new(url: nil, type: :file_upload, name: name, raw: raw)
|
|
164
|
+
end
|
|
165
|
+
|
|
154
166
|
# Wrap multiple URLs into File instances
|
|
155
167
|
#
|
|
156
168
|
# File.from_urls("https://a.com/1.pdf", "https://b.com/2.pdf")
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sequel-notion
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stéphane D'Alu
|
|
@@ -77,6 +77,7 @@ files:
|
|
|
77
77
|
- lib/sequel/notion/dataset_truncation.rb
|
|
78
78
|
- lib/sequel/notion/discovery.rb
|
|
79
79
|
- lib/sequel/notion/errors.rb
|
|
80
|
+
- lib/sequel/notion/file_uploads.rb
|
|
80
81
|
- lib/sequel/notion/filter_comparison.rb
|
|
81
82
|
- lib/sequel/notion/filter_compiler.rb
|
|
82
83
|
- lib/sequel/notion/filter_constants.rb
|