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,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../middleware/instrumentation"
4
+
5
+ module Notion
6
+ module Objects
7
+ class List < Base
8
+ include Enumerable
9
+
10
+ def results
11
+ @results ||= Array(raw["results"]).map { |result| ObjectFactory.build(result) }
12
+ end
13
+
14
+ def next_cursor = raw["next_cursor"]
15
+ def has_more? = !!raw["has_more"]
16
+
17
+ def each(&block)
18
+ return enum_for(__method__) unless block
19
+
20
+ results.each(&block)
21
+ end
22
+
23
+ def each_page
24
+ return enum_for(__method__) unless block_given?
25
+
26
+ page = self
27
+ pages = 0
28
+ results = 0
29
+ loop do
30
+ pages += 1
31
+ results += page.results.length
32
+ yield page
33
+ break unless page.has_more? && page.fetcher
34
+
35
+ page = page.fetcher.call(page.next_cursor)
36
+ end
37
+ Middleware::Instrumentation.publish("pagination.notion", pages: pages, results: results)
38
+ end
39
+
40
+ def each_result(&block)
41
+ return enum_for(__method__) unless block
42
+
43
+ each_page { |page| page.results.each(&block) }
44
+ end
45
+
46
+ def with_fetcher(&fetcher)
47
+ @fetcher = fetcher
48
+ self
49
+ end
50
+
51
+ protected
52
+
53
+ attr_reader :fetcher
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Notion
6
+ module Objects
7
+ module PropertySchema
8
+ READ_ONLY = %w[created_by created_time formula last_edited_by last_edited_time rollup unique_id].freeze
9
+ DIRECT = %w[number checkbox url email phone_number].freeze
10
+
11
+ module_function
12
+
13
+ def convert(properties, schema, strict: false)
14
+ properties.to_h do |name, value|
15
+ definition = schema[name.to_s] || schema[name.to_sym]
16
+ raise Query::UnknownPropertyError, "unknown property: #{name}" if strict && !definition
17
+
18
+ [name, definition ? encode(definition.fetch("type", definition[:type]).to_s, value) : value]
19
+ end
20
+ end
21
+
22
+ def encode(type, value)
23
+ return value if value.is_a?(Hash)
24
+
25
+ validate_writable!(type)
26
+ return { type => value } if DIRECT.include?(type)
27
+
28
+ case type
29
+ when "title", "rich_text" then { type => rich_text(value) }
30
+ when "date" then { "date" => { "start" => iso8601(value) } }
31
+ when "select", "status" then { type => value.nil? ? nil : { "name" => value.to_s } }
32
+ when "multi_select" then { type => Array(value).map { |item| { "name" => item.to_s } } }
33
+ when "people", "relation" then { type => references(value) }
34
+ else { type => value }
35
+ end
36
+ end
37
+
38
+ def validate_writable!(type)
39
+ raise ValidationError, "#{type} is read-only" if READ_ONLY.include?(type)
40
+ end
41
+
42
+ def references(value)
43
+ Array(value).map { |item| { "id" => item.respond_to?(:id) ? item.id : item } }
44
+ end
45
+
46
+ def rich_text(value)
47
+ Array(value).map do |item|
48
+ item.is_a?(Hash) ? item : { "type" => "text", "text" => { "content" => item.to_s } }
49
+ end
50
+ end
51
+
52
+ def iso8601(value)
53
+ value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module Objects
5
+ class PropertyValue < Base
6
+ TYPES = %w[
7
+ button checkbox created_by created_time date email files formula last_edited_by
8
+ last_edited_time multi_select number people phone_number place relation rich_text
9
+ rollup select status title unique_id url verification
10
+ ].freeze
11
+
12
+ def value
13
+ raw[raw["type"]]
14
+ end
15
+
16
+ def to_s
17
+ case raw["type"]
18
+ when "title", "rich_text"
19
+ Array(value).map { |item| item["plain_text"] || item.dig("text", "content") }.join
20
+ when "select", "status" then value&.fetch("name", "").to_s
21
+ when "multi_select" then Array(value).filter_map { |item| item["name"] }.join(", ")
22
+ else value.to_s
23
+ end
24
+ end
25
+
26
+ def respond_to_missing?(name, include_private = false)
27
+ (value.is_a?(Hash) && value.key?(attribute_name(name))) || super
28
+ end
29
+
30
+ def method_missing(name, *args)
31
+ key = attribute_name(name)
32
+ return super unless args.empty? && value.is_a?(Hash) && value.key?(key)
33
+
34
+ name.end_with?("?") ? !!value[key] : ObjectFactory.wrap_value(value[key])
35
+ end
36
+
37
+ def attribute_name(name)
38
+ name.to_s.delete_suffix("?")
39
+ end
40
+
41
+ def self.build(raw)
42
+ type = raw["type"].to_s
43
+ klass = const_defined?(class_name(type), false) ? const_get(class_name(type), false) : self
44
+ klass.new(raw)
45
+ end
46
+
47
+ def self.class_name(type)
48
+ type.split("_").map(&:capitalize).join
49
+ end
50
+
51
+ TYPES.each { |type| const_set(class_name(type), Class.new(self)) }
52
+
53
+ private :attribute_name
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module Objects
5
+ class Page < Base
6
+ def [](key)
7
+ property = raw.fetch("properties", {})[key.to_s]
8
+ property ? PropertyValue.build(property) : super
9
+ end
10
+
11
+ def title
12
+ title_property = raw.fetch("properties", {}).values.find { |property| property["type"] == "title" }
13
+ title_property && PropertyValue.build(title_property).to_s
14
+ end
15
+
16
+ def parent = Parent.new(raw.fetch("parent", {}))
17
+ def icon = raw["icon"] && Icon.new(raw["icon"])
18
+ end
19
+
20
+ class Database < Base; end
21
+ class DataSource < Base; end
22
+
23
+ class Block < Base
24
+ TYPES = %w[
25
+ audio bookmark breadcrumb bulleted_list_item callout child_database child_page code
26
+ column column_list divider embed equation file heading_1 heading_2 heading_3 image
27
+ link_preview link_to_page meeting_notes numbered_list_item paragraph pdf quote
28
+ synced_block table table_of_contents table_row template todo toggle transcription video
29
+ ].freeze
30
+
31
+ def self.build(raw)
32
+ name = raw["type"].to_s.split("_").map(&:capitalize).join
33
+ return new(raw) if name.empty?
34
+
35
+ klass = const_defined?(name, false) ? const_get(name, false) : self
36
+ klass.new(raw)
37
+ end
38
+
39
+ TYPES.each { |type| const_set(type.split("_").map(&:capitalize).join, Class.new(self)) }
40
+ end
41
+
42
+ class User < Base; end
43
+ class Comment < Base; end
44
+ class View < Base; end
45
+ class File < Base; end
46
+ class Parent < Base; end
47
+ class Icon < Base; end
48
+ class AsyncTask < Base; end
49
+
50
+ class RichText < Base
51
+ def to_s
52
+ raw["plain_text"] || raw.dig("text", "content").to_s
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "objects/base"
4
+ require_relative "objects/property_value"
5
+ require_relative "objects/property_schema"
6
+ require_relative "objects/types"
7
+ require_relative "objects/list"
8
+ require_relative "object_factory"
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module Pagination
5
+ class CursorPaginator
6
+ include Enumerable
7
+
8
+ def initialize(list)
9
+ @list = list
10
+ end
11
+
12
+ def each(&block)
13
+ return enum_for(__method__) unless block
14
+
15
+ @list.each_result(&block)
16
+ end
17
+
18
+ alias each_result each
19
+
20
+ def each_page(&)
21
+ @list.each_page(&)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module Query
5
+ class UnknownPropertyError < Error; end
6
+
7
+ class Builder
8
+ DEFAULT_VALUE = Object.new.freeze
9
+ OPERATORS = %i[
10
+ equals does_not_equal contains does_not_contain starts_with ends_with greater_than
11
+ less_than greater_than_or_equal_to less_than_or_equal_to before after on_or_before
12
+ on_or_after is_empty is_not_empty past_week past_month past_year next_week next_month next_year
13
+ ].freeze
14
+ EMPTY_OBJECT_OPERATORS = %i[past_week past_month past_year next_week next_month next_year].freeze
15
+
16
+ def initialize(schema: nil, strict: false)
17
+ @filters = []
18
+ @sorts = []
19
+ @schema = schema || {}
20
+ @strict = strict
21
+ end
22
+
23
+ def where(property, type: nil)
24
+ Filter.new(self, property, type)
25
+ end
26
+
27
+ def add(property, operator, value, type = nil)
28
+ raise ArgumentError, "property is required" if property.nil? || property.to_s.empty?
29
+
30
+ definition = @schema[property.to_s] || @schema[property.to_sym]
31
+ raise UnknownPropertyError, "unknown property: #{property}" if @strict && !definition
32
+
33
+ property_type = type || schema_type(definition) || :rich_text
34
+ condition = { property: property.to_s, property_type.to_sym => { operator => value } }
35
+ @filters << condition
36
+ self
37
+ end
38
+
39
+ def all_of(*filters)
40
+ raise ArgumentError, "all_of requires a filter" if filters.empty?
41
+
42
+ @filters << { and: filters.map { |filter| filter_value(filter) } }
43
+ self
44
+ end
45
+
46
+ def any_of(*filters)
47
+ raise ArgumentError, "any_of requires a filter" if filters.empty?
48
+
49
+ @filters << { or: filters.map { |filter| filter_value(filter) } }
50
+ self
51
+ end
52
+
53
+ define_method(:and) do |filter|
54
+ @filters << filter_value(filter) unless filter.equal?(self)
55
+ self
56
+ end
57
+
58
+ define_method(:or) do |filter|
59
+ right = filter.equal?(self) ? @filters.pop : filter_value(filter)
60
+ left = @filters.pop || raise(ArgumentError, "or requires a left filter")
61
+ @filters << { or: [left, right] }
62
+ self
63
+ end
64
+
65
+ def order(property = nil, timestamp: nil, direction: :ascending)
66
+ raise ArgumentError, "provide either property or timestamp" if property.nil? == timestamp.nil?
67
+ unless %i[ascending descending].include?(direction)
68
+ raise ArgumentError, "direction must be ascending or descending"
69
+ end
70
+
71
+ @sorts << { direction: direction }.merge(property ? { property: property.to_s } : { timestamp: timestamp })
72
+ self
73
+ end
74
+
75
+ def order_by_timestamp(timestamp, direction = :ascending)
76
+ order(timestamp: timestamp, direction: direction)
77
+ end
78
+
79
+ def raw(filter)
80
+ @filters << filter
81
+ self
82
+ end
83
+
84
+ def to_h
85
+ result = {}
86
+ result[:filter] = @filters.one? ? @filters.first : { and: @filters } unless @filters.empty?
87
+ result[:sorts] = @sorts unless @sorts.empty?
88
+ result
89
+ end
90
+
91
+ private
92
+
93
+ def filter_value(filter)
94
+ filter.is_a?(Builder) ? filter.to_h.fetch(:filter) : filter
95
+ end
96
+
97
+ def schema_type(definition)
98
+ definition && (definition["type"] || definition[:type])
99
+ end
100
+
101
+ class Filter
102
+ def initialize(builder, property, type)
103
+ @builder = builder
104
+ @property = property
105
+ @type = type
106
+ end
107
+
108
+ OPERATORS.each do |operator|
109
+ define_method(operator) do |value = DEFAULT_VALUE|
110
+ value = EMPTY_OBJECT_OPERATORS.include?(operator) ? {} : true if value.equal?(DEFAULT_VALUE)
111
+ @builder.add(@property, operator, value, @type)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "query/builder"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ if defined?(Rails::Railtie)
5
+ class Railtie < Rails::Railtie
6
+ initializer "notion-client-ruby.configure" do
7
+ Notion.configure { |config| config.logger ||= Rails.logger }
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ class AmbiguousDataSourceError < Error; end
5
+ class DataSourceNotFoundError < Error; end
6
+
7
+ class Resolver
8
+ TTL = 900
9
+ MAX_CACHE_SIZE = 100
10
+
11
+ def initialize(client, clock: Process.method(:clock_gettime))
12
+ @client = client
13
+ @clock = clock
14
+ @store = client.config.cache if client.respond_to?(:config)
15
+ @cache = {}
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def resolve(database_id, name: nil)
20
+ key = [database_id, name]
21
+ external = @store&.read(cache_key(key))
22
+ return external if external
23
+
24
+ cached = cached_value(key)
25
+ return cached if cached
26
+
27
+ sources = Array(@client.databases.retrieve(database_id: database_id).data_sources)
28
+ selected = select(sources, name)
29
+ id = selected.fetch("id")
30
+ write_cache(key, id)
31
+ id
32
+ end
33
+
34
+ private
35
+
36
+ def cached_value(key)
37
+ @mutex.synchronize do
38
+ expires_at, value = @cache.delete(key)
39
+ fresh = expires_at && expires_at > monotonic
40
+ @cache[key] = [expires_at, value] if fresh
41
+ value if fresh
42
+ end
43
+ end
44
+
45
+ def write_cache(key, value)
46
+ @store&.write(cache_key(key), value, expires_in: TTL)
47
+ @mutex.synchronize do
48
+ @cache[key] = [monotonic + TTL, value]
49
+ @cache.shift while @cache.length > MAX_CACHE_SIZE
50
+ end
51
+ end
52
+
53
+ def cache_key(key)
54
+ "notion:data_source:#{key.compact.join(':')}"
55
+ end
56
+
57
+ def select(sources, name)
58
+ if name
59
+ source = sources.find { |candidate| candidate["name"] == name }
60
+ return source || raise(DataSourceNotFoundError, "data source not found")
61
+ end
62
+ return sources.first if sources.one?
63
+ raise DataSourceNotFoundError, "database contains no data sources" if sources.empty?
64
+
65
+ raise AmbiguousDataSourceError, "database contains #{sources.length} data sources; pass name:"
66
+ end
67
+
68
+ def monotonic
69
+ @clock.call(Process::CLOCK_MONOTONIC)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "transport"
5
+
6
+ module Notion
7
+ module Testing
8
+ module_function
9
+
10
+ def page(id: "page", properties: {}, **attributes)
11
+ { "object" => "page", "id" => id, "properties" => properties }.merge(attributes.transform_keys(&:to_s))
12
+ end
13
+
14
+ def response(body, status: 200, headers: {})
15
+ Transport::Response.new(
16
+ status: status, headers: headers, body: JSON.generate(body), request_id: headers["x-request-id"], elapsed: 0
17
+ )
18
+ end
19
+
20
+ def stub_notion_page(id: "page", **attributes)
21
+ raise LoadError, "webmock is required for stub_notion_page" unless defined?(WebMock)
22
+
23
+ WebMock.stub_request(:get, %r{/v1/pages/#{Regexp.escape(id)}\z})
24
+ .to_return(status: 200, body: JSON.generate(page(id: id, **attributes)))
25
+ end
26
+
27
+ class Adapter < Transport::Adapter
28
+ attr_reader :requests
29
+
30
+ def initialize(*responses)
31
+ super()
32
+ @responses = responses
33
+ @requests = []
34
+ end
35
+
36
+ def call(request)
37
+ @requests << request
38
+ response = @responses.shift || raise("no stubbed Notion response")
39
+ return response if response.is_a?(Transport::Response)
40
+
41
+ Transport::Response.new(status: 200, headers: {}, body: JSON.generate(response), request_id: nil, elapsed: 0)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Notion
7
+ module Transport
8
+ class NetHTTPAdapter < Adapter
9
+ REQUEST_CLASSES = {
10
+ delete: Net::HTTP::Delete,
11
+ get: Net::HTTP::Get,
12
+ head: Net::HTTP::Head,
13
+ patch: Net::HTTP::Patch,
14
+ post: Net::HTTP::Post,
15
+ put: Net::HTTP::Put
16
+ }.freeze
17
+
18
+ def initialize(config, base_url: "https://api.notion.com", pool_size: 5, clock: Process.method(:clock_gettime))
19
+ super()
20
+ @config = config
21
+ @base_uri = URI(base_url)
22
+ @clock = clock
23
+ @pool = SizedQueue.new(pool_size)
24
+ pool_size.times { @pool << build_connection }
25
+ end
26
+
27
+ def call(request)
28
+ http = @pool.pop
29
+ broken = false
30
+ started = monotonic
31
+ http.start unless http.started?
32
+ response = http.request(build_request(request))
33
+ headers = response.each_header.to_h
34
+ Response.new(
35
+ status: response.code.to_i,
36
+ headers: headers,
37
+ body: response.body,
38
+ request_id: headers["x-request-id"],
39
+ elapsed: monotonic - started
40
+ )
41
+ rescue Timeout::Error => e
42
+ broken = true
43
+ raise TimeoutError.new(e.message, cause: e)
44
+ rescue IOError, SocketError, SystemCallError => e
45
+ broken = true
46
+ raise TransportError.new(e.message, cause: e)
47
+ ensure
48
+ release(http, broken) if http
49
+ end
50
+
51
+ private
52
+
53
+ def build_connection
54
+ proxy = @config.proxy && URI(@config.proxy)
55
+ klass = proxy ? Net::HTTP::Proxy(proxy.host, proxy.port, proxy.user, proxy.password) : Net::HTTP
56
+ http = klass.new(@base_uri.host, @base_uri.port)
57
+ http.use_ssl = @base_uri.scheme == "https"
58
+ http.open_timeout = @config.open_timeout
59
+ http.read_timeout = @config.read_timeout
60
+ http.ca_file = @config.ca_file if @config.ca_file
61
+ http
62
+ end
63
+
64
+ def build_request(request)
65
+ uri = @base_uri.dup
66
+ uri.path = request.path
67
+ uri.query = URI.encode_www_form(request.query) unless request.query.empty?
68
+ klass = REQUEST_CLASSES.fetch(request.verb) do
69
+ raise ArgumentError, "unsupported HTTP method: #{request.verb}"
70
+ end
71
+ klass.new(uri, request.headers).tap { |http_request| http_request.body = request.body if request.body }
72
+ end
73
+
74
+ def release(http, broken)
75
+ if broken
76
+ http.finish if http.started?
77
+ @pool << build_connection
78
+ else
79
+ @pool << http
80
+ end
81
+ rescue IOError
82
+ @pool << build_connection
83
+ end
84
+
85
+ def monotonic
86
+ @clock.call(Process::CLOCK_MONOTONIC)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module Transport
5
+ Request = Data.define(:verb, :path, :query, :headers, :body, :idempotent) do
6
+ def idempotent? = idempotent
7
+ end
8
+
9
+ Response = Data.define(:status, :headers, :body, :request_id, :elapsed, :attempt, :retried) do
10
+ def initialize(status:, headers:, body:, request_id:, elapsed:, attempt: 1, retried: false)
11
+ super
12
+ end
13
+ end
14
+
15
+ class Adapter
16
+ def call(_request)
17
+ raise NotImplementedError
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notion
4
+ module Webhooks
5
+ class Event < Objects::Base; end
6
+ end
7
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Notion
6
+ module Webhooks
7
+ class RackMiddleware
8
+ def initialize(app, secret:, path: "/notion/events", &handler)
9
+ @app = app
10
+ @secret = secret
11
+ @path = path
12
+ @handler = handler
13
+ end
14
+
15
+ def call(env)
16
+ return @app.call(env) unless env["PATH_INFO"] == @path && env["REQUEST_METHOD"] == "POST"
17
+
18
+ payload = env.fetch("rack.input").read
19
+ data = JSON.parse(payload)
20
+ return verify_subscription(data) if data.key?("verification_token")
21
+
22
+ valid = Signature.valid?(
23
+ secret: @secret,
24
+ payload: payload,
25
+ signature: env["HTTP_X_NOTION_SIGNATURE"] || env["HTTP_NOTION_SIGNATURE"]
26
+ )
27
+ return [401, { "content-type" => "text/plain" }, ["invalid signature"]] unless valid
28
+
29
+ @handler&.call(Event.new(data))
30
+ [200, {}, []]
31
+ rescue JSON::ParserError
32
+ [400, { "content-type" => "text/plain" }, ["invalid JSON"]]
33
+ end
34
+
35
+ private
36
+
37
+ def verify_subscription(data)
38
+ @handler&.call(Event.new(data))
39
+ [200, { "content-type" => "application/json" }, [JSON.generate(data)]]
40
+ end
41
+ end
42
+ end
43
+ end