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.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/.env.example +8 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +95 -0
  5. data/Rakefile +120 -0
  6. data/benchmark/load.rb +9 -0
  7. data/benchmark/objects.rb +14 -0
  8. data/codegen/generator.rb +277 -0
  9. data/codegen/naming.yml +61 -0
  10. data/codegen/openapi.json +47206 -0
  11. data/codegen/openapi.lock +1 -0
  12. data/codegen/overrides.yml +14 -0
  13. data/docs/design.md +7 -0
  14. data/docs/recipes/csv-import.md +14 -0
  15. data/docs/recipes/oauth.md +9 -0
  16. data/docs/recipes/page-sync.md +10 -0
  17. data/docs/recipes/pagination.md +9 -0
  18. data/docs/recipes/uploads.md +9 -0
  19. data/docs/recipes/webhooks.md +28 -0
  20. data/lib/generators/notion/install_generator.rb +15 -0
  21. data/lib/generators/notion/templates/notion.rb +5 -0
  22. data/lib/notion/batch.rb +45 -0
  23. data/lib/notion/blocks/builder.rb +57 -0
  24. data/lib/notion/blocks/chunker.rb +70 -0
  25. data/lib/notion/blocks.rb +4 -0
  26. data/lib/notion/client.rb +138 -0
  27. data/lib/notion/compat/legacy.rb +47 -0
  28. data/lib/notion/compat.rb +94 -0
  29. data/lib/notion/config.rb +88 -0
  30. data/lib/notion/endpoints/async_tasks.rb +26 -0
  31. data/lib/notion/endpoints/base.rb +107 -0
  32. data/lib/notion/endpoints/blocks.rb +39 -0
  33. data/lib/notion/endpoints/data_sources.rb +30 -0
  34. data/lib/notion/endpoints/file_uploads.rb +35 -0
  35. data/lib/notion/endpoints/pages.rb +56 -0
  36. data/lib/notion/endpoints/views.rb +16 -0
  37. data/lib/notion/errors.rb +111 -0
  38. data/lib/notion/experimental.rb +12 -0
  39. data/lib/notion/file_uploader.rb +142 -0
  40. data/lib/notion/generated/endpoints/agents.rb +64 -0
  41. data/lib/notion/generated/endpoints/async_tasks.rb +20 -0
  42. data/lib/notion/generated/endpoints/blocks.rb +49 -0
  43. data/lib/notion/generated/endpoints/comments.rb +48 -0
  44. data/lib/notion/generated/endpoints/custom_emojis.rb +20 -0
  45. data/lib/notion/generated/endpoints/data_sources.rb +49 -0
  46. data/lib/notion/generated/endpoints/databases.rb +34 -0
  47. data/lib/notion/generated/endpoints/file_uploads.rb +51 -0
  48. data/lib/notion/generated/endpoints/meeting_notes.rb +27 -0
  49. data/lib/notion/generated/endpoints/oauth.rb +34 -0
  50. data/lib/notion/generated/endpoints/pages.rb +65 -0
  51. data/lib/notion/generated/endpoints/search.rb +20 -0
  52. data/lib/notion/generated/endpoints/sessions.rb +48 -0
  53. data/lib/notion/generated/endpoints/users.rb +34 -0
  54. data/lib/notion/generated/endpoints/views.rb +73 -0
  55. data/lib/notion/generated.rb +41 -0
  56. data/lib/notion/id.rb +14 -0
  57. data/lib/notion/markdown/local_converter.rb +34 -0
  58. data/lib/notion/middleware/api_version.rb +18 -0
  59. data/lib/notion/middleware/authentication.rb +52 -0
  60. data/lib/notion/middleware/compatibility.rb +17 -0
  61. data/lib/notion/middleware/instrumentation.rb +63 -0
  62. data/lib/notion/middleware/json_codec.rb +37 -0
  63. data/lib/notion/middleware/logging.rb +36 -0
  64. data/lib/notion/middleware/rate_limiter.rb +78 -0
  65. data/lib/notion/middleware/retry.rb +45 -0
  66. data/lib/notion/middleware/stack.rb +20 -0
  67. data/lib/notion/middleware.rb +11 -0
  68. data/lib/notion/multipart.rb +39 -0
  69. data/lib/notion/oauth.rb +91 -0
  70. data/lib/notion/object_factory.rb +39 -0
  71. data/lib/notion/objects/base.rb +62 -0
  72. data/lib/notion/objects/list.rb +56 -0
  73. data/lib/notion/objects/property_schema.rb +57 -0
  74. data/lib/notion/objects/property_value.rb +56 -0
  75. data/lib/notion/objects/types.rb +56 -0
  76. data/lib/notion/objects.rb +8 -0
  77. data/lib/notion/pagination/cursor_paginator.rb +25 -0
  78. data/lib/notion/query/builder.rb +117 -0
  79. data/lib/notion/query.rb +3 -0
  80. data/lib/notion/railtie.rb +11 -0
  81. data/lib/notion/resolver.rb +72 -0
  82. data/lib/notion/testing.rb +45 -0
  83. data/lib/notion/transport/net_http_adapter.rb +90 -0
  84. data/lib/notion/transport.rb +21 -0
  85. data/lib/notion/version.rb +5 -0
  86. data/lib/notion/webhooks/event.rb +7 -0
  87. data/lib/notion/webhooks/rack_middleware.rb +43 -0
  88. data/lib/notion/webhooks/signature.rb +22 -0
  89. data/lib/notion/webhooks.rb +7 -0
  90. data/lib/notion-client-ruby.rb +3 -0
  91. data/lib/notion.rb +53 -0
  92. metadata +132 -0
@@ -0,0 +1 @@
1
+ sha256: dee5763763b0b9fbad2aa8d5adb173ca350ec26dda557e658c5dbe9d2ea2f258
@@ -0,0 +1,14 @@
1
+ post-page:
2
+ required_body_params:
3
+ - parent
4
+ body_requirement_variants:
5
+ - - parent
6
+ upload-file:
7
+ required_body_params:
8
+ - data
9
+ - filename
10
+ - content_type
11
+ body_requirement_variants:
12
+ - - data
13
+ - filename
14
+ - content_type
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
+ # Large queries
2
+
3
+ ```ruby
4
+ client.data_sources.query(data_source_id: id, page_size: 100)
5
+ .each_result
6
+ .lazy
7
+ .take(1_000)
8
+ .each { |page| process(page) }
9
+ ```
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Notion.configure do |config|
4
+ config.token = ENV.fetch("NOTION_TOKEN")
5
+ end
@@ -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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "blocks/builder"
4
+ require_relative "blocks/chunker"
@@ -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