notion-client-ruby 0.1.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.
- checksums.yaml +7 -0
- data/.env.example +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +95 -0
- data/Rakefile +120 -0
- data/benchmark/load.rb +9 -0
- data/benchmark/objects.rb +14 -0
- data/codegen/generator.rb +277 -0
- data/codegen/naming.yml +61 -0
- data/codegen/openapi.json +47206 -0
- data/codegen/openapi.lock +1 -0
- data/codegen/overrides.yml +14 -0
- data/docs/design.md +7 -0
- data/docs/recipes/csv-import.md +14 -0
- data/docs/recipes/oauth.md +9 -0
- data/docs/recipes/page-sync.md +10 -0
- data/docs/recipes/pagination.md +9 -0
- data/docs/recipes/uploads.md +9 -0
- data/docs/recipes/webhooks.md +28 -0
- data/lib/generators/notion/install_generator.rb +15 -0
- data/lib/generators/notion/templates/notion.rb +5 -0
- data/lib/notion/batch.rb +45 -0
- data/lib/notion/blocks/builder.rb +57 -0
- data/lib/notion/blocks/chunker.rb +70 -0
- data/lib/notion/blocks.rb +4 -0
- data/lib/notion/client.rb +138 -0
- data/lib/notion/compat/legacy.rb +47 -0
- data/lib/notion/compat.rb +94 -0
- data/lib/notion/config.rb +88 -0
- data/lib/notion/endpoints/async_tasks.rb +26 -0
- data/lib/notion/endpoints/base.rb +107 -0
- data/lib/notion/endpoints/blocks.rb +39 -0
- data/lib/notion/endpoints/data_sources.rb +30 -0
- data/lib/notion/endpoints/file_uploads.rb +35 -0
- data/lib/notion/endpoints/pages.rb +56 -0
- data/lib/notion/endpoints/views.rb +16 -0
- data/lib/notion/errors.rb +111 -0
- data/lib/notion/experimental.rb +12 -0
- data/lib/notion/file_uploader.rb +142 -0
- data/lib/notion/generated/endpoints/agents.rb +64 -0
- data/lib/notion/generated/endpoints/async_tasks.rb +20 -0
- data/lib/notion/generated/endpoints/blocks.rb +49 -0
- data/lib/notion/generated/endpoints/comments.rb +48 -0
- data/lib/notion/generated/endpoints/custom_emojis.rb +20 -0
- data/lib/notion/generated/endpoints/data_sources.rb +49 -0
- data/lib/notion/generated/endpoints/databases.rb +34 -0
- data/lib/notion/generated/endpoints/file_uploads.rb +51 -0
- data/lib/notion/generated/endpoints/meeting_notes.rb +27 -0
- data/lib/notion/generated/endpoints/oauth.rb +34 -0
- data/lib/notion/generated/endpoints/pages.rb +65 -0
- data/lib/notion/generated/endpoints/search.rb +20 -0
- data/lib/notion/generated/endpoints/sessions.rb +48 -0
- data/lib/notion/generated/endpoints/users.rb +34 -0
- data/lib/notion/generated/endpoints/views.rb +73 -0
- data/lib/notion/generated.rb +41 -0
- data/lib/notion/id.rb +14 -0
- data/lib/notion/markdown/local_converter.rb +34 -0
- data/lib/notion/middleware/api_version.rb +18 -0
- data/lib/notion/middleware/authentication.rb +52 -0
- data/lib/notion/middleware/compatibility.rb +17 -0
- data/lib/notion/middleware/instrumentation.rb +63 -0
- data/lib/notion/middleware/json_codec.rb +37 -0
- data/lib/notion/middleware/logging.rb +36 -0
- data/lib/notion/middleware/rate_limiter.rb +78 -0
- data/lib/notion/middleware/retry.rb +45 -0
- data/lib/notion/middleware/stack.rb +20 -0
- data/lib/notion/middleware.rb +11 -0
- data/lib/notion/multipart.rb +39 -0
- data/lib/notion/oauth.rb +91 -0
- data/lib/notion/object_factory.rb +39 -0
- data/lib/notion/objects/base.rb +62 -0
- data/lib/notion/objects/list.rb +56 -0
- data/lib/notion/objects/property_schema.rb +57 -0
- data/lib/notion/objects/property_value.rb +56 -0
- data/lib/notion/objects/types.rb +56 -0
- data/lib/notion/objects.rb +8 -0
- data/lib/notion/pagination/cursor_paginator.rb +25 -0
- data/lib/notion/query/builder.rb +117 -0
- data/lib/notion/query.rb +3 -0
- data/lib/notion/railtie.rb +11 -0
- data/lib/notion/resolver.rb +72 -0
- data/lib/notion/testing.rb +45 -0
- data/lib/notion/transport/net_http_adapter.rb +90 -0
- data/lib/notion/transport.rb +21 -0
- data/lib/notion/version.rb +5 -0
- data/lib/notion/webhooks/event.rb +7 -0
- data/lib/notion/webhooks/rack_middleware.rb +43 -0
- data/lib/notion/webhooks/signature.rb +22 -0
- data/lib/notion/webhooks.rb +7 -0
- data/lib/notion-client-ruby.rb +3 -0
- data/lib/notion.rb +53 -0
- metadata +132 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sha256: dee5763763b0b9fbad2aa8d5adb173ca350ec26dda557e658c5dbe9d2ea2f258
|
data/docs/design.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
The client is split into transport, middleware, generated endpoints, response objects, and optional ergonomics. The core uses only Ruby standard libraries.
|
|
4
|
+
|
|
5
|
+
Requests flow through instrumentation, redacted logging, retry, token-bucket rate limiting, authentication, API-version compatibility, and JSON encoding before `Net::HTTP`. Endpoint declarations and coverage specs are generated from the vendored official OpenAPI document. Unknown response fields and object types remain accessible through `raw` and `[]`.
|
|
6
|
+
|
|
7
|
+
Compatibility normalizes legacy `archived`, `after`, and `transcription` fields to the `2026-03-11` vocabulary. New endpoints remain reachable immediately through `Client#request` before a generated update is released.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# CSV import
|
|
2
|
+
|
|
3
|
+
```ruby
|
|
4
|
+
require "csv"
|
|
5
|
+
|
|
6
|
+
CSV.foreach("tasks.csv", headers: true) do |row|
|
|
7
|
+
client.pages.create(
|
|
8
|
+
parent: { data_source_id: data_source_id },
|
|
9
|
+
properties: row.to_h
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Property values are converted using the data source schema. Enable `strict: true` to reject unknown CSV headers before creating a page.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# OAuth
|
|
2
|
+
|
|
3
|
+
```ruby
|
|
4
|
+
oauth = Notion::OAuth::Client.new(client_id:, client_secret:, redirect_uri:)
|
|
5
|
+
redirect_to oauth.authorize_url(state: session[:oauth_state])
|
|
6
|
+
token = oauth.exchange(code: params[:code])
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Validate `state` in the callback before exchanging the code. Store access and refresh tokens encrypted at rest.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Page synchronization
|
|
2
|
+
|
|
3
|
+
Store each page's `id` and `last_edited_time`. On later runs, query only changed rows:
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
client.data_sources.sync_since(data_source_id: id, time: last_sync)
|
|
7
|
+
.each_result { |page| upsert(page.id, page.raw) }
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Webhook events are signals, not complete page snapshots; retrieve the page again before persisting it.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# File uploads
|
|
2
|
+
|
|
3
|
+
```ruby
|
|
4
|
+
upload = client.upload_file(path: "report.pdf", content_type: "application/pdf")
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
Files over 20 MiB use the multipart API automatically. Pass `on_progress:` for progress notifications. External imports accept only HTTP(S) URLs.
|
|
8
|
+
|
|
9
|
+
`client.import_file(url: ...)` waits for the asynchronous import by default. Pass `wait: false` to receive the pending upload immediately.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Webhooks
|
|
2
|
+
|
|
3
|
+
```ruby
|
|
4
|
+
use Notion::Webhooks::RackMiddleware, secret: ENV.fetch("NOTION_WEBHOOK_SECRET") do |event|
|
|
5
|
+
EventJob.perform_later(event.raw)
|
|
6
|
+
end
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The middleware verifies Notion's `X-Notion-Signature` raw-body HMAC in constant time. Initial verification-token requests are acknowledged automatically.
|
|
10
|
+
|
|
11
|
+
The same middleware works in Sinatra:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
class WebhookApp < Sinatra::Base
|
|
15
|
+
use Notion::Webhooks::RackMiddleware, secret: ENV.fetch("NOTION_WEBHOOK_SECRET") do |event|
|
|
16
|
+
Events.process(event.raw)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
In Rails, add it to `config/application.rb`:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
config.middleware.use Notion::Webhooks::RackMiddleware,
|
|
25
|
+
secret: ENV.fetch("NOTION_WEBHOOK_SECRET") do |event|
|
|
26
|
+
EventJob.perform_later(event.raw)
|
|
27
|
+
end
|
|
28
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Notion
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
def copy_initializer
|
|
11
|
+
template "notion.rb", "config/initializers/notion.rb"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/notion/batch.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Notion
|
|
4
|
+
class Batch
|
|
5
|
+
def initialize(concurrency)
|
|
6
|
+
unless concurrency.is_a?(Integer) && concurrency.positive?
|
|
7
|
+
raise ArgumentError, "concurrency must be a positive integer"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
@concurrency = concurrency
|
|
11
|
+
@jobs = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(&job)
|
|
15
|
+
raise ArgumentError, "a job block is required" unless job
|
|
16
|
+
|
|
17
|
+
@jobs << job
|
|
18
|
+
self
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
queue = Queue.new
|
|
23
|
+
@jobs.each_with_index { |job, index| queue << [index, job] }
|
|
24
|
+
results = Array.new(@jobs.length)
|
|
25
|
+
errors = Array.new(@jobs.length)
|
|
26
|
+
Array.new([@concurrency, @jobs.length].min) do
|
|
27
|
+
Thread.new do
|
|
28
|
+
while (entry = queue.pop(true))
|
|
29
|
+
index, job = entry
|
|
30
|
+
begin
|
|
31
|
+
results[index] = job.call
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
errors[index] = e
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
rescue ThreadError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end.each(&:join)
|
|
40
|
+
raise errors.compact.first if errors.any?
|
|
41
|
+
|
|
42
|
+
results
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Notion
|
|
4
|
+
module Blocks
|
|
5
|
+
class Builder
|
|
6
|
+
ANNOTATIONS = %i[bold italic strikethrough underline code color].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :blocks
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@blocks = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def method_missing(type, content = nil, **options, &block)
|
|
15
|
+
children = self.class.build(&block) if block
|
|
16
|
+
annotations = options.slice(*ANNOTATIONS)
|
|
17
|
+
payload = options.except(*ANNOTATIONS).merge("rich_text" => rich_text(content, annotations))
|
|
18
|
+
payload["children"] = children if children
|
|
19
|
+
@blocks << { "object" => "block", "type" => type.to_s, type.to_s => payload.compact }
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def respond_to_missing?(_name, _include_private = false) = true
|
|
24
|
+
|
|
25
|
+
def self.build(&)
|
|
26
|
+
builder = new
|
|
27
|
+
builder.instance_eval(&)
|
|
28
|
+
builder.blocks
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def rich_text(content, annotations)
|
|
34
|
+
return content if content.is_a?(Array)
|
|
35
|
+
return [] if content.nil?
|
|
36
|
+
|
|
37
|
+
segments(content.to_s, annotations).map do |segment|
|
|
38
|
+
item = { "type" => "text", "text" => { "content" => segment } }
|
|
39
|
+
item["annotations"] = annotation_values(segment, annotations) unless annotations.empty?
|
|
40
|
+
item
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def segments(text, annotations)
|
|
45
|
+
marked = annotations.values.grep(Array).flatten.map(&:to_s)
|
|
46
|
+
marked.empty? ? [text] : text.split(/(#{Regexp.union(marked)})/).reject(&:empty?)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def annotation_values(segment, annotations)
|
|
50
|
+
annotations.to_h do |name, value|
|
|
51
|
+
enabled = value.is_a?(Array) ? value.map(&:to_s).include?(segment) : value
|
|
52
|
+
[name.to_s, enabled]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Notion
|
|
6
|
+
module Blocks
|
|
7
|
+
class Chunker
|
|
8
|
+
MAX_CHILDREN = 100
|
|
9
|
+
MAX_BYTES = 500_000
|
|
10
|
+
MAX_BLOCKS = 1000
|
|
11
|
+
|
|
12
|
+
def initialize(children, on_oversize: :split)
|
|
13
|
+
raise ArgumentError, "on_oversize must be :split or :raise" unless %i[split raise].include?(on_oversize)
|
|
14
|
+
|
|
15
|
+
@on_oversize = on_oversize
|
|
16
|
+
@children = transform(children)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def chunks
|
|
20
|
+
@children.each_with_object([[]]) do |child, groups|
|
|
21
|
+
raise ValidationError, "a block exceeds the 500KB request limit" if bytes([child]) > MAX_BYTES
|
|
22
|
+
raise ValidationError, "a block tree exceeds the 1000 block limit" if block_count(child) > MAX_BLOCKS
|
|
23
|
+
|
|
24
|
+
groups << [] if full?(groups.last, child)
|
|
25
|
+
groups.last << child
|
|
26
|
+
end.reject(&:empty?)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def bytes(children)
|
|
32
|
+
JSON.generate(children: children).bytesize
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def full?(group, child)
|
|
36
|
+
group.length >= MAX_CHILDREN || bytes(group + [child]) > MAX_BYTES ||
|
|
37
|
+
block_count(group) + block_count(child) > MAX_BLOCKS
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def transform(value)
|
|
41
|
+
case value
|
|
42
|
+
when Hash then value.to_h { |key, item| [key, transform(item)] }
|
|
43
|
+
when Array then value.flat_map { |item| rich_text_item?(item) ? split_item(item) : [transform(item)] }
|
|
44
|
+
else value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def rich_text_item?(value)
|
|
49
|
+
value.is_a?(Hash) && value.dig("text", "content").is_a?(String)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def split_item(item)
|
|
53
|
+
text = item.dig("text", "content")
|
|
54
|
+
raise ValidationError, "rich text exceeds 2000 characters" if @on_oversize == :raise && text.length > 2000
|
|
55
|
+
|
|
56
|
+
text.scan(/.{1,2000}/m).map do |content|
|
|
57
|
+
transform(item.merge("text" => item.fetch("text").merge("content" => content)))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def block_count(value)
|
|
62
|
+
case value
|
|
63
|
+
when Hash then (value["object"] == "block" ? 1 : 0) + value.values.sum { |item| block_count(item) }
|
|
64
|
+
when Array then value.sum { |item| block_count(item) }
|
|
65
|
+
else 0
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "transport"
|
|
4
|
+
require_relative "transport/net_http_adapter"
|
|
5
|
+
require_relative "compat"
|
|
6
|
+
require_relative "middleware"
|
|
7
|
+
require_relative "objects"
|
|
8
|
+
require_relative "pagination/cursor_paginator"
|
|
9
|
+
require_relative "query"
|
|
10
|
+
require_relative "blocks"
|
|
11
|
+
require_relative "batch"
|
|
12
|
+
require_relative "resolver"
|
|
13
|
+
require_relative "multipart"
|
|
14
|
+
require_relative "file_uploader"
|
|
15
|
+
require_relative "experimental"
|
|
16
|
+
require_relative "endpoints/base"
|
|
17
|
+
require_relative "generated"
|
|
18
|
+
require_relative "endpoints/blocks"
|
|
19
|
+
require_relative "endpoints/data_sources"
|
|
20
|
+
require_relative "endpoints/pages"
|
|
21
|
+
require_relative "endpoints/file_uploads"
|
|
22
|
+
require_relative "endpoints/async_tasks"
|
|
23
|
+
require_relative "endpoints/views"
|
|
24
|
+
|
|
25
|
+
module Notion
|
|
26
|
+
class Client
|
|
27
|
+
attr_reader :config
|
|
28
|
+
|
|
29
|
+
def initialize(base_url: "https://api.notion.com", **options)
|
|
30
|
+
@config = Notion.configuration.merge(**options)
|
|
31
|
+
@config.betas = @config.betas.dup.freeze
|
|
32
|
+
@config.freeze
|
|
33
|
+
adapter = @config.adapter
|
|
34
|
+
adapter = nil if adapter == :net_http
|
|
35
|
+
adapter ||= Transport::NetHTTPAdapter.new(@config, base_url: base_url)
|
|
36
|
+
@app = Middleware::Stack.build(adapter, @config)
|
|
37
|
+
@endpoints = {}
|
|
38
|
+
@endpoints_mutex = Mutex.new
|
|
39
|
+
@resolver = Resolver.new(self)
|
|
40
|
+
@file_uploader = FileUploader.new(self)
|
|
41
|
+
@schema_cache = {}
|
|
42
|
+
@schema_mutex = Mutex.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def request(method, path, query: {}, headers: {}, body: nil, idempotent: nil)
|
|
46
|
+
method = method.to_sym
|
|
47
|
+
raise ArgumentError, "path must start with /" unless path.start_with?("/")
|
|
48
|
+
|
|
49
|
+
response = @app.call(
|
|
50
|
+
Transport::Request.new(
|
|
51
|
+
verb: method,
|
|
52
|
+
path: path,
|
|
53
|
+
query: query.compact,
|
|
54
|
+
headers: headers.transform_keys { |key| key.to_s.downcase },
|
|
55
|
+
body: body,
|
|
56
|
+
idempotent: idempotent.nil? ? %i[get delete].include?(method) : idempotent
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
raise APIError.from_response(response) unless response.status.between?(200, 299)
|
|
60
|
+
|
|
61
|
+
ObjectFactory.build(response.body)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def inspect
|
|
65
|
+
"#<#{self.class} notion_version=#{config.notion_version.inspect}>"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
Generated::RESOURCES.each_key do |resource|
|
|
69
|
+
define_method(resource) { endpoint(resource) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def search(**params, &)
|
|
73
|
+
endpoint(:search).search(**params, &)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def resolve_data_source(database_id:, name: nil)
|
|
77
|
+
@resolver.resolve(database_id, name: name)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def query(id, name: nil, **params, &)
|
|
81
|
+
return data_sources.query(data_source_id: id, **params, &) if config.notion_version == "2022-06-28"
|
|
82
|
+
|
|
83
|
+
data_source_id = resolve_data_source(database_id: id, name: name)
|
|
84
|
+
data_sources.query(data_source_id: data_source_id, **params, &)
|
|
85
|
+
rescue ObjectNotFoundError
|
|
86
|
+
data_sources.query(data_source_id: id, **params, &)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def upload_file(**)
|
|
90
|
+
@file_uploader.upload(**)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def import_file(**)
|
|
94
|
+
@file_uploader.import(**)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def batch(concurrency: 3)
|
|
98
|
+
raise ArgumentError, "a batch block is required" unless block_given?
|
|
99
|
+
|
|
100
|
+
jobs = Batch.new(concurrency)
|
|
101
|
+
yield jobs
|
|
102
|
+
jobs.run
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def experimental
|
|
106
|
+
@endpoints_mutex.synchronize { @experimental ||= Experimental.new(self) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def schema_for(data_source_id)
|
|
110
|
+
cache_key = "notion:schema:#{data_source_id}"
|
|
111
|
+
external = config.cache&.read(cache_key)
|
|
112
|
+
return external if external
|
|
113
|
+
|
|
114
|
+
@schema_mutex.synchronize do
|
|
115
|
+
@schema_cache[data_source_id] ||= begin
|
|
116
|
+
schema = data_sources.retrieve(data_source_id: data_source_id).raw.fetch("properties", {})
|
|
117
|
+
config.cache&.write(cache_key, schema, expires_in: 900)
|
|
118
|
+
schema
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def clear_schema_cache(data_source_id = nil)
|
|
124
|
+
config.cache&.delete("notion:schema:#{data_source_id}") if data_source_id
|
|
125
|
+
@schema_mutex.synchronize do
|
|
126
|
+
data_source_id ? @schema_cache.delete(data_source_id) : @schema_cache.clear
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def endpoint(resource)
|
|
133
|
+
@endpoints_mutex.synchronize do
|
|
134
|
+
@endpoints[resource] ||= Generated::RESOURCES.fetch(resource).new(self)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "notion"
|
|
4
|
+
|
|
5
|
+
module Notion
|
|
6
|
+
class Client
|
|
7
|
+
alias modern_search search
|
|
8
|
+
|
|
9
|
+
def database_query(database_id:, **params, &)
|
|
10
|
+
deprecate(:database_query, "query")
|
|
11
|
+
query(database_id, **params, &)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def database(database_id:)
|
|
15
|
+
deprecate(:database, "databases.retrieve")
|
|
16
|
+
databases.retrieve(database_id: database_id)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def page(page_id:)
|
|
20
|
+
deprecate(:page, "pages.retrieve")
|
|
21
|
+
pages.retrieve(page_id: page_id)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create_page(**params)
|
|
25
|
+
deprecate(:create_page, "pages.create")
|
|
26
|
+
pages.create(**params)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def block_children(block_id:, **params)
|
|
30
|
+
deprecate(:block_children, "blocks.children")
|
|
31
|
+
blocks.children(block_id: block_id, **params)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def search(**params, &block)
|
|
35
|
+
result = modern_search(**params)
|
|
36
|
+
return result unless block
|
|
37
|
+
|
|
38
|
+
result.each_result(&block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def deprecate(old_name, replacement)
|
|
44
|
+
warn "Notion::Client##{old_name} is deprecated; use #{replacement}", uplevel: 2
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Notion
|
|
4
|
+
module Compat
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def request(version, request)
|
|
8
|
+
reject_unsupported!(version, request.path)
|
|
9
|
+
return request if version == "2026-03-11"
|
|
10
|
+
|
|
11
|
+
path = request.path
|
|
12
|
+
path = path.sub(%r{\A/v1/data_sources(?=/|\z)}, "/v1/databases") if version == "2022-06-28"
|
|
13
|
+
request.with(path: path, body: downgrade(request.body, version))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def response(version, response)
|
|
17
|
+
return response if version == "2026-03-11" || !response.body.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
response.with(body: normalize(response.body, version))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def downgrade(value, version)
|
|
23
|
+
case value
|
|
24
|
+
when Array then value.map { |item| downgrade(item, version) }
|
|
25
|
+
when Hash then value.to_h { |key, item| downgrade_pair(key, item, version) }
|
|
26
|
+
else value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def downgrade_pair(key, item, version)
|
|
31
|
+
reject_meeting_notes!(key, item, version)
|
|
32
|
+
if key.to_s == "position" && item.is_a?(Hash) && item["type"] == "after_block"
|
|
33
|
+
return ["after", item.dig("after_block", "id")]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
new_key = downgrade_key(key, version)
|
|
37
|
+
new_item = downgrade_value(key, item, version)
|
|
38
|
+
[new_key, new_item]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reject_meeting_notes!(key, item, version)
|
|
42
|
+
meeting_notes = key.to_s == "meeting_notes" || (key.to_s == "type" && item == "meeting_notes")
|
|
43
|
+
return unless version == "2022-06-28" && meeting_notes
|
|
44
|
+
|
|
45
|
+
raise UnsupportedInVersionError, "meeting_notes is unavailable in Notion API #{version}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def downgrade_value(key, item, version)
|
|
49
|
+
return "transcription" if key.to_s == "type" && item == "meeting_notes"
|
|
50
|
+
|
|
51
|
+
downgrade(item, version)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def downgrade_key(key, version)
|
|
55
|
+
key = key.to_s
|
|
56
|
+
return "archived" if key == "in_trash"
|
|
57
|
+
return "transcription" if version == "2025-09-03" && key == "meeting_notes"
|
|
58
|
+
return "database_id" if version == "2022-06-28" && key == "data_source_id"
|
|
59
|
+
|
|
60
|
+
key
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalize(value, version)
|
|
64
|
+
value.each_with_object({}) do |(key, item), result|
|
|
65
|
+
new_key = { "archived" => "in_trash", "transcription" => "meeting_notes" }.fetch(key, key)
|
|
66
|
+
new_key = "data_source_id" if version == "2022-06-28" && new_key == "database_id"
|
|
67
|
+
result[new_key] = if key == "type" && item == "transcription"
|
|
68
|
+
"meeting_notes"
|
|
69
|
+
else
|
|
70
|
+
normalize_value(item, version)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def normalize_value(item, version)
|
|
76
|
+
case item
|
|
77
|
+
when Hash then normalize(item, version)
|
|
78
|
+
when Array then item.map { |entry| normalize_value(entry, version) }
|
|
79
|
+
else item
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reject_unsupported!(version, path)
|
|
84
|
+
return if version == "2026-03-11"
|
|
85
|
+
|
|
86
|
+
unsupported = path.match?(%r{\A/v1/(views|agents|sessions)(?=/|\z)}) || path.end_with?("/markdown")
|
|
87
|
+
unsupported ||= path.match?(%r{\A/v1/blocks/meeting_notes(?=/|\z)})
|
|
88
|
+
unsupported ||= version == "2022-06-28" && path.match?(%r{\A/v1/data_sources/[^/]+/templates})
|
|
89
|
+
return unless unsupported
|
|
90
|
+
|
|
91
|
+
raise UnsupportedInVersionError, "#{path} is unavailable in Notion API #{version}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Notion
|
|
4
|
+
class ConfigurationError < StandardError; end
|
|
5
|
+
|
|
6
|
+
class Config
|
|
7
|
+
API_VERSIONS = %w[2022-06-28 2025-09-03 2026-03-11].freeze
|
|
8
|
+
|
|
9
|
+
attr_accessor :token, :notion_version, :betas, :open_timeout, :read_timeout,
|
|
10
|
+
:max_attempts, :retry_cap, :rate, :burst, :logger, :log_level,
|
|
11
|
+
:user_agent, :ca_file, :proxy, :strict, :adapter, :token_store,
|
|
12
|
+
:token_key, :oauth_client, :auto_refresh, :cache, :rate_limit_store
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@token = ENV.fetch("NOTION_TOKEN", nil)
|
|
16
|
+
@notion_version = ENV.fetch("NOTION_API_VERSION", "2026-03-11")
|
|
17
|
+
@betas = []
|
|
18
|
+
@open_timeout = 5
|
|
19
|
+
@read_timeout = 65
|
|
20
|
+
@max_attempts = 5
|
|
21
|
+
@retry_cap = 30
|
|
22
|
+
@rate = 3.0
|
|
23
|
+
@burst = 6
|
|
24
|
+
@log_level = :info
|
|
25
|
+
@user_agent = "notion-client-ruby/#{Notion::VERSION} (ruby/#{RUBY_VERSION})"
|
|
26
|
+
@strict = false
|
|
27
|
+
@token_key = :default
|
|
28
|
+
@auto_refresh = false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def timeout=(values)
|
|
32
|
+
self.open_timeout = values.fetch(:open, open_timeout)
|
|
33
|
+
self.read_timeout = values.fetch(:read, read_timeout)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def retry=(values)
|
|
37
|
+
self.max_attempts = values.fetch(:max_attempts, max_attempts)
|
|
38
|
+
self.retry_cap = values.fetch(:cap, retry_cap)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def rate_limit=(values)
|
|
42
|
+
self.rate = values.fetch(:rate, rate)
|
|
43
|
+
self.burst = values.fetch(:burst, burst)
|
|
44
|
+
self.rate_limit_store = values[:store] == :memory ? nil : values[:store] if values.key?(:store)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate!(require_token: false)
|
|
48
|
+
raise ConfigurationError, "token is required" if require_token && blank?(@token) && !@token_store
|
|
49
|
+
unless API_VERSIONS.include?(@notion_version)
|
|
50
|
+
raise ConfigurationError, "unsupported Notion API version: #{@notion_version}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
positive_values.each do |name, value|
|
|
54
|
+
raise ConfigurationError, "#{name} must be positive" unless value.to_f.positive?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def merge(**options)
|
|
61
|
+
copy = dup
|
|
62
|
+
options.each do |name, value|
|
|
63
|
+
writer = "#{name}="
|
|
64
|
+
raise ConfigurationError, "unknown option: #{name}" unless copy.respond_to?(writer)
|
|
65
|
+
|
|
66
|
+
copy.public_send(writer, value)
|
|
67
|
+
end
|
|
68
|
+
copy.validate!
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def positive_values
|
|
74
|
+
{
|
|
75
|
+
open_timeout: @open_timeout,
|
|
76
|
+
read_timeout: @read_timeout,
|
|
77
|
+
max_attempts: @max_attempts,
|
|
78
|
+
retry_cap: @retry_cap,
|
|
79
|
+
rate: @rate,
|
|
80
|
+
burst: @burst
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def blank?(value)
|
|
85
|
+
value.nil? || value.empty?
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|