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,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,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
|
data/lib/notion/query.rb
ADDED
|
@@ -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,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
|