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