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,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module Markdown
5
+ module LocalConverter
6
+ module_function
7
+
8
+ def to_blocks(markdown)
9
+ markdown.lines.filter_map do |line|
10
+ text = line.chomp
11
+ next if text.empty?
12
+
13
+ hashes, content = text.match(/\A(\#{1,3})\s+(.*)\z/)&.captures
14
+ type = hashes ? "heading_#{hashes.length}" : "paragraph"
15
+ content ||= text
16
+ { "object" => "block", "type" => type, type => rich_text(content) }
17
+ end
18
+ end
19
+
20
+ def to_markdown(blocks)
21
+ blocks.map do |block|
22
+ type = block["type"]
23
+ rich_text = Array(block.dig(type, "rich_text"))
24
+ text = rich_text.map { |item| item["plain_text"] || item.dig("text", "content") }.join
25
+ type.start_with?("heading_") ? "#{'#' * type.delete_prefix('heading_').to_i} #{text}" : text
26
+ end.join("\n\n")
27
+ end
28
+
29
+ def rich_text(content)
30
+ { "rich_text" => [{ "type" => "text", "text" => { "content" => content } }] }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module Middleware
5
+ class ApiVersion
6
+ def initialize(app, config)
7
+ @app = app
8
+ @config = config
9
+ end
10
+
11
+ def call(request)
12
+ headers = request.headers.merge("notion-version" => @config.notion_version)
13
+ headers["notion-beta"] = @config.betas.join(",") unless @config.betas.empty?
14
+ @app.call(request.with(headers: headers))
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module Middleware
5
+ class Authentication
6
+ def initialize(app, config)
7
+ @app = app
8
+ @config = config
9
+ @refresh_mutex = Mutex.new
10
+ end
11
+
12
+ def call(request)
13
+ token = access_token
14
+ raise ConfigurationError, "token is required" if token.nil? || token.empty?
15
+
16
+ headers = request.headers.merge(
17
+ "authorization" => "Bearer #{token}",
18
+ "user-agent" => @config.user_agent
19
+ )
20
+ @app.call(request.with(headers: headers))
21
+ end
22
+
23
+ private
24
+
25
+ def access_token
26
+ token = @config.token_store&.read(@config.token_key)
27
+ token = refresh_expired(token) if expired?(token) && @config.auto_refresh
28
+ token&.access_token || @config.token
29
+ end
30
+
31
+ def refresh_expired(token)
32
+ @refresh_mutex.synchronize do
33
+ current = @config.token_store.read(@config.token_key)
34
+ expired?(current) ? refresh(current || token) : current
35
+ end
36
+ end
37
+
38
+ def expired?(token)
39
+ token&.expires_at && token.expires_at <= Time.now
40
+ end
41
+
42
+ def refresh(token)
43
+ raise ConfigurationError, "oauth_client is required for token refresh" unless @config.oauth_client
44
+ raise ConfigurationError, "refresh token is missing" unless token.refresh_token
45
+
46
+ refreshed = @config.oauth_client.refresh(refresh_token: token.refresh_token)
47
+ @config.token_store.write(@config.token_key, refreshed)
48
+ refreshed
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module Middleware
5
+ class Compatibility
6
+ def initialize(app, config)
7
+ @app = app
8
+ @version = config.notion_version
9
+ end
10
+
11
+ def call(request)
12
+ response = @app.call(Compat.request(@version, request))
13
+ Compat.response(@version, response)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module Middleware
5
+ class Instrumentation
6
+ @subscribers = []
7
+ @mutex = Mutex.new
8
+
9
+ class << self
10
+ attr_reader :subscribers
11
+
12
+ def subscribe(&block)
13
+ @mutex.synchronize { subscribers << block }
14
+ block
15
+ end
16
+
17
+ def unsubscribe(block)
18
+ @mutex.synchronize { subscribers.delete(block) }
19
+ end
20
+
21
+ def each_subscriber(&)
22
+ @mutex.synchronize { subscribers.dup }.each(&)
23
+ end
24
+
25
+ def publish(name, payload)
26
+ each_subscriber { |subscriber| subscriber.call(name, payload) }
27
+ ActiveSupport::Notifications.instrument(name, payload) if defined?(ActiveSupport::Notifications)
28
+ end
29
+ end
30
+
31
+ def initialize(app, clock: Process.method(:clock_gettime))
32
+ @app = app
33
+ @clock = clock
34
+ end
35
+
36
+ def call(request)
37
+ started = monotonic
38
+ response = @app.call(request)
39
+ publish(request, response, monotonic - started)
40
+ response
41
+ end
42
+
43
+ private
44
+
45
+ def publish(request, response, duration)
46
+ payload = {
47
+ method: request.verb,
48
+ path: request.path,
49
+ status: response.status,
50
+ duration: duration,
51
+ request_id: response.request_id,
52
+ attempt: response.attempt,
53
+ retried: response.retried
54
+ }
55
+ self.class.publish("request.notion", payload)
56
+ end
57
+
58
+ def monotonic
59
+ @clock.call(Process::CLOCK_MONOTONIC)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Notion
6
+ module Middleware
7
+ class JsonCodec
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(request)
13
+ response = @app.call(encode(request))
14
+ response.with(body: decode(response))
15
+ end
16
+
17
+ private
18
+
19
+ def encode(request)
20
+ return request unless request.body.is_a?(Hash) || request.body.is_a?(Array)
21
+
22
+ request.with(
23
+ body: JSON.generate(request.body),
24
+ headers: request.headers.merge("content-type" => "application/json")
25
+ )
26
+ end
27
+
28
+ def decode(response)
29
+ return nil if response.body.nil? || response.body.empty?
30
+
31
+ JSON.parse(response.body)
32
+ rescue JSON::ParserError
33
+ response.body
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Notion
6
+ module Middleware
7
+ class Logging
8
+ SENSITIVE = /authorization|token|secret/i
9
+
10
+ def initialize(app, logger:, level: :info)
11
+ @app = app
12
+ @logger = logger
13
+ @level = level
14
+ end
15
+
16
+ def call(request)
17
+ return @app.call(request) unless @logger
18
+
19
+ log(event: "request", method: request.verb, path: request.path, headers: redact(request.headers))
20
+ response = @app.call(request)
21
+ log(event: "response", status: response.status, request_id: response.request_id)
22
+ response
23
+ end
24
+
25
+ private
26
+
27
+ def redact(value)
28
+ value.to_h { |key, item| [key, key.to_s.match?(SENSITIVE) ? "[REDACTED]" : item] }
29
+ end
30
+
31
+ def log(payload)
32
+ @logger.public_send(@level, JSON.generate(payload))
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module Middleware
5
+ class RateLimiter
6
+ State = Struct.new(:tokens, :updated_at, :blocked_until, :mutex)
7
+
8
+ class MemoryStore
9
+ def initialize
10
+ @states = {}
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def state(key, burst, now)
15
+ return State.new(burst, now, now, Mutex.new) unless key
16
+
17
+ @mutex.synchronize { @states[key] ||= State.new(burst, now, now, Mutex.new) }
18
+ end
19
+ end
20
+
21
+ DEFAULT_STORE = MemoryStore.new
22
+
23
+ def initialize(
24
+ app, rate:, burst:, key: nil, store: DEFAULT_STORE,
25
+ clock: Process.method(:clock_gettime), sleeper: Kernel.method(:sleep)
26
+ )
27
+ @app = app
28
+ @rate = rate.to_f
29
+ @burst = burst.to_f
30
+ @clock = clock
31
+ @sleeper = sleeper
32
+ @state = store.state(key && [key, @rate, @burst], @burst, monotonic)
33
+ end
34
+
35
+ def call(request)
36
+ wait
37
+ response = @app.call(request)
38
+ pause(response.headers["retry-after"].to_f, response) if response.status == 429
39
+ response
40
+ end
41
+
42
+ private
43
+
44
+ def wait
45
+ loop do
46
+ delay = @state.mutex.synchronize { next_delay }
47
+ return unless delay.positive?
48
+
49
+ @sleeper.call(delay)
50
+ end
51
+ end
52
+
53
+ def next_delay
54
+ now = monotonic
55
+ return @state.blocked_until - now if now < @state.blocked_until
56
+
57
+ @state.tokens = [@burst, @state.tokens + ((now - @state.updated_at) * @rate)].min
58
+ @state.updated_at = now
59
+ return (1.0 - @state.tokens) / @rate if @state.tokens < 1.0
60
+
61
+ @state.tokens -= 1.0
62
+ 0
63
+ end
64
+
65
+ def pause(seconds, response)
66
+ @state.mutex.synchronize do
67
+ @state.blocked_until = [@state.blocked_until, monotonic + seconds].max
68
+ end
69
+ reason = response.body&.dig("additional_data", "rate_limit_reason") if response.body.is_a?(Hash)
70
+ Instrumentation.publish("rate_limited.notion", retry_after: seconds, reason: reason)
71
+ end
72
+
73
+ def monotonic
74
+ @clock.call(Process::CLOCK_MONOTONIC)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module Middleware
5
+ class Retry
6
+ RETRYABLE = [429, 529].freeze
7
+ IDEMPOTENT_RETRYABLE = [500, 502, 503, 504].freeze
8
+
9
+ def initialize(app, max_attempts:, cap:, sleeper: Kernel.method(:sleep), random: Random.new)
10
+ @app = app
11
+ @max_attempts = max_attempts
12
+ @cap = cap
13
+ @sleeper = sleeper
14
+ @random = random
15
+ end
16
+
17
+ def call(request)
18
+ attempt = 0
19
+ loop do
20
+ attempt += 1
21
+ response = @app.call(request)
22
+ unless attempt < @max_attempts && retryable?(response, request)
23
+ return response.with(attempt: attempt, retried: attempt > 1)
24
+ end
25
+
26
+ @sleeper.call(delay(response, attempt))
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def retryable?(response, request)
33
+ RETRYABLE.include?(response.status) ||
34
+ (request.idempotent? && IDEMPOTENT_RETRYABLE.include?(response.status))
35
+ end
36
+
37
+ def delay(response, attempt)
38
+ retry_after = response.headers["retry-after"]
39
+ return retry_after.to_f if retry_after
40
+
41
+ @random.rand * [2**(attempt - 1), @cap].min
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module Middleware
5
+ class Stack
6
+ def self.build(adapter, config)
7
+ app = JsonCodec.new(adapter)
8
+ app = Compatibility.new(app, config)
9
+ app = ApiVersion.new(app, config)
10
+ app = Authentication.new(app, config)
11
+ key = config.token || (config.token_store && [config.token_store.object_id, config.token_key])
12
+ store = config.rate_limit_store || RateLimiter::DEFAULT_STORE
13
+ app = RateLimiter.new(app, rate: config.rate, burst: config.burst, key: key, store: store)
14
+ app = Retry.new(app, max_attempts: config.max_attempts, cap: config.retry_cap)
15
+ app = Logging.new(app, logger: config.logger, level: config.log_level)
16
+ Instrumentation.new(app)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "middleware/stack"
4
+ require_relative "middleware/json_codec"
5
+ require_relative "middleware/compatibility"
6
+ require_relative "middleware/api_version"
7
+ require_relative "middleware/authentication"
8
+ require_relative "middleware/rate_limiter"
9
+ require_relative "middleware/retry"
10
+ require_relative "middleware/logging"
11
+ require_relative "middleware/instrumentation"
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Notion
6
+ module Multipart
7
+ module_function
8
+
9
+ def encode(data, filename:, content_type:, part_number: nil)
10
+ filename = quoted_header_value(filename, "filename")
11
+ content_type = header_value(content_type, "content_type")
12
+ boundary = "notion-#{SecureRandom.hex(12)}"
13
+ body = +"".b
14
+ append_field(body, boundary, "part_number", part_number.to_s) if part_number
15
+ body << "--#{boundary}\r\n"
16
+ body << %(Content-Disposition: form-data; name="file"; filename="#{filename}"\r\n)
17
+ body << "Content-Type: #{content_type}\r\n\r\n"
18
+ body << data.b << "\r\n--#{boundary}--\r\n"
19
+ [body, "multipart/form-data; boundary=#{boundary}"]
20
+ end
21
+
22
+ def header_value(value, name)
23
+ value = value.to_s
24
+ raise ArgumentError, "#{name} cannot contain newlines" if value.match?(/[\r\n]/)
25
+
26
+ value
27
+ end
28
+
29
+ def quoted_header_value(value, name)
30
+ header_value(value, name).gsub(/[\\"]/) { |character| "\\#{character}" }
31
+ end
32
+
33
+ def append_field(body, boundary, name, value)
34
+ body << "--#{boundary}\r\n"
35
+ body << %(Content-Disposition: form-data; name="#{name}"\r\n\r\n)
36
+ body << "#{value}\r\n"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Notion
8
+ module OAuth
9
+ Token = Data.define(:access_token, :refresh_token, :bot_id, :workspace_id, :expires_at, :raw)
10
+
11
+ class Client
12
+ def initialize(client_id:, client_secret:, redirect_uri:, base_url: "https://api.notion.com")
13
+ @client_id = client_id
14
+ @client_secret = client_secret
15
+ @redirect_uri = redirect_uri
16
+ @base_url = base_url
17
+ end
18
+
19
+ def authorize_url(state:, owner: "user")
20
+ query = URI.encode_www_form(
21
+ client_id: @client_id,
22
+ response_type: "code",
23
+ owner: owner,
24
+ redirect_uri: @redirect_uri,
25
+ state: state
26
+ )
27
+ "https://api.notion.com/v1/oauth/authorize?#{query}"
28
+ end
29
+
30
+ def exchange(code:)
31
+ token_request(grant_type: "authorization_code", code: code, redirect_uri: @redirect_uri)
32
+ end
33
+
34
+ def refresh(refresh_token:)
35
+ token_request(grant_type: "refresh_token", refresh_token: refresh_token)
36
+ end
37
+
38
+ def introspect(token:)
39
+ request("/v1/oauth/introspect", token: token)
40
+ end
41
+
42
+ def revoke(token:)
43
+ request("/v1/oauth/revoke", token: token)
44
+ end
45
+
46
+ private
47
+
48
+ def token_request(**body)
49
+ data = request("/v1/oauth/token", body)
50
+ Token.new(
51
+ access_token: data["access_token"],
52
+ refresh_token: data["refresh_token"],
53
+ bot_id: data["bot_id"],
54
+ workspace_id: data["workspace_id"],
55
+ expires_at: data["expires_in"] && (Time.now + data["expires_in"]),
56
+ raw: data
57
+ )
58
+ end
59
+
60
+ def request(path, body)
61
+ uri = URI.join(@base_url, path)
62
+ request = Net::HTTP::Post.new(uri)
63
+ credentials = ["#{@client_id}:#{@client_secret}"].pack("m0")
64
+ request["authorization"] = "Basic #{credentials}"
65
+ request["content-type"] = "application/json"
66
+ request.body = JSON.generate(body)
67
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
68
+ data = JSON.parse(response.body)
69
+ raise Error, data["message"] || "OAuth request failed" unless response.is_a?(Net::HTTPSuccess)
70
+
71
+ data
72
+ end
73
+ end
74
+
75
+ class TokenStore
76
+ def read(_key) = raise(NotImplementedError)
77
+ def write(_key, _token) = raise(NotImplementedError)
78
+ end
79
+
80
+ class MemoryTokenStore < TokenStore
81
+ def initialize
82
+ super
83
+ @tokens = {}
84
+ @mutex = Mutex.new
85
+ end
86
+
87
+ def read(key) = @mutex.synchronize { @tokens[key] }
88
+ def write(key, token) = @mutex.synchronize { @tokens[key] = token }
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module ObjectFactory
5
+ TYPES = {
6
+ "page" => Objects::Page,
7
+ "database" => Objects::Database,
8
+ "data_source" => Objects::DataSource,
9
+ "block" => Objects::Block,
10
+ "user" => Objects::User,
11
+ "comment" => Objects::Comment,
12
+ "view" => Objects::View,
13
+ "file_upload" => Objects::File,
14
+ "async_task" => Objects::AsyncTask,
15
+ "list" => Objects::List
16
+ }.freeze
17
+
18
+ module_function
19
+
20
+ def build(value)
21
+ return wrap_value(value) unless value.is_a?(Hash)
22
+ if !value.key?("object") && value.key?("type") && value.key?(value["type"])
23
+ return Objects::PropertyValue.build(value)
24
+ end
25
+ return Objects::Block.build(value) if value["object"] == "block"
26
+
27
+ klass = value.key?("results") ? Objects::List : TYPES.fetch(value["object"], Objects::Unknown)
28
+ klass.new(value)
29
+ end
30
+
31
+ def wrap_value(value)
32
+ case value
33
+ when Hash then value.key?("object") ? build(value) : value.transform_values { |item| wrap_value(item) }
34
+ when Array then value.map { |item| wrap_value(item) }
35
+ else value
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module Objects
5
+ class Base
6
+ attr_reader :raw
7
+
8
+ def initialize(raw)
9
+ @raw = deep_freeze(raw)
10
+ end
11
+
12
+ def [](key)
13
+ value_for(key.to_s)
14
+ end
15
+
16
+ def deconstruct_keys(keys)
17
+ selected = keys || raw.keys
18
+ selected.to_h { |key| [key.to_sym, value_for(key.to_s)] }
19
+ end
20
+
21
+ def in_trash?
22
+ !!(raw["in_trash"] || raw["archived"])
23
+ end
24
+
25
+ def inspect
26
+ attributes = %w[object id type].filter_map { |key| "#{key}=#{raw[key].inspect}" if raw.key?(key) }
27
+ "#<#{self.class} #{attributes.join(' ')}>"
28
+ end
29
+
30
+ def respond_to_missing?(name, include_private = false)
31
+ raw.key?(attribute_name(name)) || super
32
+ end
33
+
34
+ def method_missing(name, *args)
35
+ key = attribute_name(name)
36
+ return super unless args.empty? && raw.key?(key)
37
+
38
+ name.end_with?("?") ? !!raw[key] : value_for(key)
39
+ end
40
+
41
+ private
42
+
43
+ def deep_freeze(value)
44
+ case value
45
+ when Hash then value.each_value { |item| deep_freeze(item) }
46
+ when Array then value.each { |item| deep_freeze(item) }
47
+ end
48
+ value.freeze
49
+ end
50
+
51
+ def attribute_name(name)
52
+ name.to_s.delete_suffix("?")
53
+ end
54
+
55
+ def value_for(key)
56
+ ObjectFactory.wrap_value(raw[key])
57
+ end
58
+ end
59
+
60
+ class Unknown < Base; end
61
+ end
62
+ end