wikiwiki 0.5.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.
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wikiwiki
4
+ # Rate limiter with pluggable strategies
5
+ #
6
+ # @example Raise on limit
7
+ # limiter = Wikiwiki::RateLimiter.raise_on_limit([
8
+ # { window: 60, max_requests: 120 }, # 120 requests per minute
9
+ # { window: 3600, max_requests: 2000 } # 2000 requests per hour
10
+ # ])
11
+ # limiter.acquire! # raises RateLimitError if limit exceeded
12
+ #
13
+ # @example Wait on limit
14
+ # limiter = Wikiwiki::RateLimiter.wait_on_limit([
15
+ # { window: 60, max_requests: 120 }
16
+ # ])
17
+ # limiter.acquire! # waits automatically if limit exceeded
18
+ #
19
+ # @example No limit
20
+ # limiter = Wikiwiki::RateLimiter.no_limit
21
+ # limiter.acquire! # always succeeds immediately
22
+ class RateLimiter
23
+ # Default rate limits for Wikiwiki REST API
24
+ WIKIWIKI_API_LIMITS = [
25
+ {window: 60, max_requests: 120}, # 120 requests per minute
26
+ {window: 3600, max_requests: 2000} # 2000 requests per hour
27
+ ].freeze
28
+ public_constant :WIKIWIKI_API_LIMITS
29
+
30
+ # Create a rate limiter that raises error when limit is exceeded
31
+ #
32
+ # @param limits [Array<Hash>] array of limit configurations
33
+ # @option limits [Integer] :window time window in seconds
34
+ # @option limits [Integer] :max_requests maximum requests allowed in the window
35
+ # @return [RateLimiter]
36
+ # @raise [ArgumentError] if limits is empty
37
+ def self.raise_on_limit(limits)
38
+ raise ArgumentError, "limits cannot be empty (use .no_limit for no rate limiting)" if limits.empty?
39
+
40
+ new(limits, Strategy::Raise.new)
41
+ end
42
+
43
+ # Create a rate limiter that waits when limit is exceeded
44
+ #
45
+ # @param limits [Array<Hash>] array of limit configurations
46
+ # @option limits [Integer] :window time window in seconds
47
+ # @option limits [Integer] :max_requests maximum requests allowed in the window
48
+ # @return [RateLimiter]
49
+ # @raise [ArgumentError] if limits is empty
50
+ def self.wait_on_limit(limits)
51
+ raise ArgumentError, "limits cannot be empty (use .no_limit for no rate limiting)" if limits.empty?
52
+
53
+ new(limits, Strategy::Wait.new)
54
+ end
55
+
56
+ # Create a null rate limiter that never limits
57
+ #
58
+ # @return [RateLimiter]
59
+ def self.no_limit = new([], Strategy::Null.new)
60
+
61
+ # Create a rate limiter with default limits (120 requests/min, 2000 requests/hour)
62
+ #
63
+ # These are the rate limits for the Wikiwiki REST API.
64
+ #
65
+ # @return [RateLimiter]
66
+ def self.default = wait_on_limit(WIKIWIKI_API_LIMITS)
67
+
68
+ private_class_method :new
69
+
70
+ # @param limits [Array<Hash>] array of limit configurations
71
+ # @param strategy [Strategy] rate limiting strategy
72
+ def initialize(limits, strategy)
73
+ @windows = limits.map {|limit| SlidingWindow.new(**limit) }
74
+ @mutex = Mutex.new
75
+ @strategy = strategy
76
+ end
77
+
78
+ # Acquire permission to make a request
79
+ #
80
+ # Behavior depends on the strategy:
81
+ # - raise_on_limit: raises RateLimitError if limit exceeded
82
+ # - wait_on_limit: waits until request can be made
83
+ # - no_limit: always succeeds immediately
84
+ #
85
+ # @return [void]
86
+ # @raise [RateLimitError] if rate limit is exceeded (with raise strategy)
87
+ def acquire!
88
+ @strategy.acquire!(self)
89
+ end
90
+
91
+ # Get time in seconds until next request can be made
92
+ #
93
+ # @return [Float] seconds to wait (0.0 if request can be made now)
94
+ def wait_time_until_available
95
+ @mutex.synchronize do
96
+ @windows.map(&:wait_time).max || 0.0
97
+ end
98
+ end
99
+
100
+ # Check if a request can be made without exceeding any limits
101
+ #
102
+ # @return [Boolean] true if request is allowed
103
+ def can_request?
104
+ @windows.all?(&:can_request?)
105
+ end
106
+
107
+ # Record a new request across all window limiters
108
+ #
109
+ # @return [void]
110
+ def record!
111
+ @windows.each(&:record!)
112
+ end
113
+
114
+ # Get maximum wait time across all window limiters
115
+ #
116
+ # @return [Float, nil] seconds to wait
117
+ def wait_time
118
+ @windows.map(&:wait_time).max
119
+ end
120
+
121
+ attr_reader :mutex
122
+ end
123
+
124
+ # Rate limiting strategies
125
+ module Strategy
126
+ # Strategy that raises error when limit is exceeded
127
+ class Raise
128
+ # @param limiter [RateLimiter]
129
+ # @return [void]
130
+ # @raise [RateLimitError] if rate limit is exceeded
131
+ def acquire!(limiter)
132
+ limiter.mutex.synchronize do
133
+ raise RateLimitError, "Rate limit exceeded" unless limiter.can_request?
134
+
135
+ limiter.record!
136
+ end
137
+ end
138
+ end
139
+
140
+ # Strategy that waits when limit is exceeded
141
+ class Wait
142
+ # @param limiter [RateLimiter]
143
+ # @return [void]
144
+ def acquire!(limiter)
145
+ limiter.mutex.synchronize do
146
+ until limiter.can_request?
147
+ wait_time = limiter.wait_time
148
+ sleep wait_time if wait_time&.positive?
149
+ end
150
+ limiter.record!
151
+ end
152
+ end
153
+ end
154
+
155
+ # Strategy that never limits (null object pattern)
156
+ class Null
157
+ # @param limiter [RateLimiter]
158
+ # @return [void]
159
+ def acquire!(limiter)
160
+ # no-op
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wikiwiki
4
+ # Sliding window algorithm for rate limiting
5
+ class SlidingWindow
6
+ # Initialize a new window limiter
7
+ #
8
+ # @param window [Integer] time window in seconds
9
+ # @param max_requests [Integer] maximum requests allowed in the window
10
+ def initialize(window:, max_requests:)
11
+ @window = window
12
+ @max_requests = max_requests
13
+ @requests = []
14
+ end
15
+
16
+ # Check if a request can be made without exceeding the limit
17
+ #
18
+ # @return [Boolean] true if request is allowed
19
+ def can_request?
20
+ cleanup!
21
+ @requests.size < @max_requests
22
+ end
23
+
24
+ # Record a new request timestamp
25
+ #
26
+ # @return [void]
27
+ def record!
28
+ @requests << Time.now
29
+ end
30
+
31
+ # Get time in seconds until next request can be made
32
+ #
33
+ # @return [Float] seconds to wait (0.0 if request can be made now)
34
+ def wait_time
35
+ cleanup!
36
+ return 0.0 if @requests.size < @max_requests
37
+
38
+ # Time until oldest request expires
39
+ oldest = @requests.first
40
+ time_until_expiry = @window - (Time.now - oldest)
41
+ [time_until_expiry, 0.0].max
42
+ end
43
+
44
+ private def cleanup!
45
+ cutoff = Time.now - @window
46
+ @requests.reject! {|timestamp| timestamp < cutoff }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wikiwiki
4
+ # The gem version
5
+ VERSION = "0.5.0"
6
+ public_constant :VERSION
7
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest/md5"
5
+ require "erb"
6
+ require "logger"
7
+ require "time"
8
+
9
+ module Wikiwiki
10
+ # Represents a Wikiwiki wiki instance
11
+ #
12
+ # @example
13
+ # auth = Wikiwiki::Auth.password(password: "admin_password")
14
+ # wiki = Wikiwiki::Wiki.new(wiki_id: "my-wiki", auth:)
15
+ # wiki.page_names # => ["FrontPage", "SideBar", ...]
16
+ # page = wiki.page(page_name: "FrontPage")
17
+ class Wiki
18
+ attr_reader :logger
19
+
20
+ private attr_reader :api, :wiki_id
21
+
22
+ # Initializes a new Wiki instance
23
+ #
24
+ # @param wiki_id [String] the wiki ID
25
+ # @param auth [Wikiwiki::Auth::Password, Wikiwiki::Auth::ApiKey] authentication credentials
26
+ # @param rate_limiter [RateLimiter] rate limiter instance (default: RateLimiter.default)
27
+ # @param logger [Logger] logger instance for API request/response logging (default: Logger.new($stdout))
28
+ # @raise [ArgumentError] if logger is explicitly set to nil
29
+ def initialize(wiki_id:, auth:, rate_limiter: RateLimiter.default, logger: Logger.new($stdout))
30
+ raise ArgumentError, "logger cannot be nil" if logger.nil?
31
+
32
+ @wiki_id = wiki_id
33
+ @logger = logger
34
+ @api = API.new(wiki_id:, auth:, logger:, rate_limiter:)
35
+ end
36
+
37
+ # Returns the wiki URL
38
+ #
39
+ # @return [URI::HTTPS] the frozen wiki URL
40
+ def url
41
+ @url ||= URI.parse("https://wikiwiki.jp/#{wiki_id}/").freeze
42
+ end
43
+
44
+ # Returns the list of all page names in the wiki
45
+ #
46
+ # @return [Array<String>] array of page names
47
+ def page_names = api.get_pages["pages"].map {|p| p["name"] }
48
+
49
+ # Retrieves a page by name
50
+ #
51
+ # @param page_name [String] the name of the page
52
+ # @return [Wikiwiki::Page] the page object
53
+ # @raise [Wikiwiki::Error] if the page does not exist
54
+ def page(page_name:)
55
+ encoded_page_name = ERB::Util.url_encode(page_name)
56
+ page_data = api.get_page(encoded_page_name:)
57
+ Page.new(
58
+ name: page_name,
59
+ source: page_data["source"],
60
+ # NOTE: API returns timestamp as ISO8601 string for pages
61
+ timestamp: Time.iso8601(page_data["timestamp"])
62
+ )
63
+ end
64
+
65
+ # Updates a page with new content
66
+ #
67
+ # @param page_name [String] the name of the page to update
68
+ # @param source [String] the new page source content
69
+ # @return [void]
70
+ # @raise [Wikiwiki::Error] if the update fails
71
+ def update_page(page_name:, source:)
72
+ encoded_page_name = ERB::Util.url_encode(page_name)
73
+ api.put_page(encoded_page_name:, source:)
74
+ end
75
+
76
+ # Retrieves attachment file names on a page
77
+ #
78
+ # @param page_name [String] the name of the page
79
+ # @return [Array<String>] array of attachment file names
80
+ # @raise [Wikiwiki::Error] if the request fails
81
+ def attachment_names(page_name:)
82
+ encoded_page_name = ERB::Util.url_encode(page_name)
83
+ attachments_data = api.get_attachments(encoded_page_name:)
84
+ attachments = attachments_data["attachments"]
85
+ # API returns [] when no attachments, or Hash when attachments exist
86
+ attachments.is_a?(Hash) ? attachments.keys : []
87
+ end
88
+
89
+ # Retrieves a specific attachment on a page
90
+ #
91
+ # @param page_name [String] the name of the page
92
+ # @param attachment_name [String] the name of the attachment file
93
+ # @param rev [String, nil] optional MD5 hash to retrieve a specific revision
94
+ # @return [Wikiwiki::Attachment] the attachment object with decoded file data
95
+ # @raise [Wikiwiki::Error] if the attachment does not exist
96
+ # @raise [ArgumentError] if rev is not a valid MD5 hash format
97
+ def attachment(page_name:, attachment_name:, rev: nil)
98
+ if rev && !rev.match?(/\A[0-9a-f]{32}\z/i)
99
+ raise ArgumentError, "rev must be a valid MD5 hash (32 hexadecimal characters)"
100
+ end
101
+
102
+ encoded_page_name = ERB::Util.url_encode(page_name)
103
+ encoded_attachment_name = ERB::Util.url_encode(attachment_name)
104
+ data = api.get_attachment(encoded_page_name:, encoded_attachment_name:, rev:)
105
+
106
+ content = Base64.strict_decode64(data["src"])
107
+
108
+ if content.bytesize != data["size"]
109
+ raise ContentIntegrityError, "Size mismatch: expected #{data["size"]}, got #{content.bytesize}"
110
+ end
111
+
112
+ calculated_md5 = Digest::MD5.hexdigest(content)
113
+ if calculated_md5 != data["md5hash"]
114
+ raise ContentIntegrityError, "MD5 hash mismatch: expected #{data["md5hash"]}, got #{calculated_md5}"
115
+ end
116
+
117
+ Attachment.new(
118
+ page_name: data["page"],
119
+ name: data["file"],
120
+ size: data["size"],
121
+ # NOTE: API returns time as Unix timestamp (integer) for attachments, not ISO8601 string
122
+ time: Time.at(data["time"]),
123
+ type: data["type"],
124
+ content:
125
+ )
126
+ end
127
+
128
+ # Adds an attachment to a page
129
+ #
130
+ # @param page_name [String] the name of the page
131
+ # @param attachment_name [String] the name of the file
132
+ # @param content [String] the binary file content
133
+ # @return [void]
134
+ # @raise [Wikiwiki::Error] if the upload fails
135
+ def add_attachment(page_name:, attachment_name:, content:)
136
+ encoded_page_name = ERB::Util.url_encode(page_name)
137
+ encoded_content = Base64.strict_encode64(content)
138
+ api.put_attachment(encoded_page_name:, attachment_name:, encoded_content:)
139
+ end
140
+
141
+ # Deletes an attachment from a page
142
+ #
143
+ # @param page_name [String] the name of the page
144
+ # @param attachment_name [String] the name of the attachment file
145
+ # @return [void]
146
+ # @raise [Wikiwiki::Error] if the deletion fails
147
+ def delete_attachment(page_name:, attachment_name:)
148
+ encoded_page_name = ERB::Util.url_encode(page_name)
149
+ encoded_attachment_name = ERB::Util.url_encode(attachment_name)
150
+ api.delete_attachment(encoded_page_name:, encoded_attachment_name:)
151
+ end
152
+ end
153
+ end
data/lib/wikiwiki.rb ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require_relative "wikiwiki/version"
5
+
6
+ # Wikiwiki REST API client library
7
+ #
8
+ # @example
9
+ # auth = Wikiwiki::Auth.password(password: "admin_password")
10
+ # wiki = Wikiwiki::Wiki.new(wiki_id: "my-wiki", auth:)
11
+ # page = wiki.page(page_name: "FrontPage")
12
+ # page.name # => "FrontPage"
13
+ module Wikiwiki
14
+ # Base error class for Wikiwiki gem
15
+ class Error < StandardError; end
16
+
17
+ # Rate limit exceeded error
18
+ class RateLimitError < Error; end
19
+
20
+ # Content integrity error
21
+ # Raised when downloaded file content doesn't match expected size or MD5 hash
22
+ class ContentIntegrityError < Error; end
23
+
24
+ # API request error
25
+ class APIError < Error; end
26
+
27
+ # Authentication error
28
+ # Raised when HTTP status is 401
29
+ class AuthenticationError < APIError; end
30
+
31
+ # Resource not found error
32
+ # Raised when HTTP status is 404
33
+ class ResourceNotFoundError < APIError; end
34
+
35
+ # Server error
36
+ # Raised when HTTP status is 5xx
37
+ class ServerError < APIError; end
38
+
39
+ loader = Zeitwerk::Loader.for_gem
40
+ loader.inflector.inflect("api" => "API")
41
+ loader.setup
42
+ loader.eager_load
43
+ end
data/mise.toml ADDED
@@ -0,0 +1,6 @@
1
+ [tools]
2
+ ruby = "3.2"
3
+
4
+ [env]
5
+ _.path = ["bin", "exe"]
6
+ _.file = '.env'
@@ -0,0 +1,12 @@
1
+ # Download sources
2
+ sources:
3
+ - type: stdlib
4
+
5
+ # A directory to install the downloaded RBSs
6
+ path: .gem_rbs_collection
7
+
8
+ # A directory to install the downloaded RBSs
9
+ gems:
10
+ # Use standard library type definitions
11
+ - name: net-http
12
+ - name: uri
@@ -0,0 +1,36 @@
1
+ module Wikiwiki
2
+ class API
3
+ attr_reader logger: Logger
4
+
5
+ def initialize: (wiki_id: String, auth: Auth::Password | Auth::ApiKey, logger: Logger, ?rate_limiter: RateLimiter) -> void
6
+
7
+ def get_pages: () -> Hash[String, Array[Hash[String, String]]]
8
+
9
+ def get_page: (encoded_page_name: String) -> Hash[String, String]
10
+
11
+ def put_page: (encoded_page_name: String, source: String) -> void
12
+
13
+ def get_attachments: (encoded_page_name: String) -> Hash[String, Hash[String, Hash[String, untyped]]]
14
+
15
+ def get_attachment: (encoded_page_name: String, encoded_attachment_name: String, ?rev: String?) -> Hash[String, untyped]
16
+
17
+ def put_attachment: (encoded_page_name: String, attachment_name: String, encoded_content: String) -> void
18
+
19
+ def delete_attachment: (encoded_page_name: String, encoded_attachment_name: String) -> void
20
+
21
+ private
22
+
23
+ def authenticate: (Auth::Password | Auth::ApiKey auth) -> String
24
+
25
+ def parse_json_response: (Net::HTTPResponse response) -> Hash[String, untyped]
26
+
27
+ def request: (Symbol method, URI::HTTPS uri, ?body: Hash[String, String]?, ?authenticate: bool) -> Net::HTTPResponse
28
+
29
+ def log_request: (Symbol method, URI::HTTPS uri, Net::HTTPRequest request) -> void
30
+
31
+ def log_response: (Net::HTTPResponse response) -> void
32
+
33
+ attr_reader wiki_id: String
34
+ attr_reader token: String
35
+ end
36
+ end
@@ -0,0 +1,12 @@
1
+ module Wikiwiki
2
+ class Attachment
3
+ attr_reader page_name: String
4
+ attr_reader name: String
5
+ attr_reader size: Integer
6
+ attr_reader time: Time
7
+ attr_reader type: String
8
+ attr_reader content: String
9
+
10
+ def initialize: (page_name: String, name: String, size: Integer, time: Time, type: String, content: String) -> void
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ module Wikiwiki
2
+ module Auth
3
+ class ApiKey
4
+ attr_reader api_key_id: String
5
+ attr_reader secret: String
6
+
7
+ def initialize: (api_key_id: String, secret: String) -> void
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ module Wikiwiki
2
+ module Auth
3
+ class Password
4
+ attr_reader password: String
5
+
6
+ def initialize: (password: String) -> void
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ module Wikiwiki
2
+ module Auth
3
+ def self.password: (String password) -> Auth::Password
4
+
5
+ def self.api_key: (String api_key_id, String secret) -> Auth::ApiKey
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ module Wikiwiki
2
+ class Page
3
+ attr_reader name: String
4
+ attr_reader source: String
5
+ attr_reader timestamp: Time
6
+
7
+ def initialize: (name: String, source: String, timestamp: Time) -> void
8
+ end
9
+ end
@@ -0,0 +1,41 @@
1
+ module Wikiwiki
2
+ class RateLimiter
3
+ def self.raise_on_limit: (Array[{ window: Integer, max_requests: Integer }] limits) -> RateLimiter
4
+
5
+ def self.wait_on_limit: (Array[{ window: Integer, max_requests: Integer }] limits) -> RateLimiter
6
+
7
+ def self.no_limit: () -> RateLimiter
8
+
9
+ def self.default: () -> RateLimiter
10
+
11
+ def acquire!: () -> void
12
+
13
+ def wait_time_until_available: () -> Float
14
+
15
+ def can_request?: () -> bool
16
+
17
+ def record!: () -> void
18
+
19
+ def wait_time: () -> Float?
20
+
21
+ attr_reader mutex: Thread::Mutex
22
+ end
23
+
24
+ module Strategy
25
+ interface _Strategy
26
+ def acquire!: (RateLimiter limiter) -> void
27
+ end
28
+
29
+ class Raise
30
+ include _Strategy
31
+ end
32
+
33
+ class Wait
34
+ include _Strategy
35
+ end
36
+
37
+ class Null
38
+ include _Strategy
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,19 @@
1
+ module Wikiwiki
2
+ class SlidingWindow
3
+ def initialize: (window: Integer, max_requests: Integer) -> void
4
+
5
+ def can_request?: () -> bool
6
+
7
+ def record!: () -> void
8
+
9
+ def wait_time: () -> Float
10
+
11
+ private
12
+
13
+ def cleanup!: () -> void
14
+
15
+ attr_reader window: Integer
16
+ attr_reader max_requests: Integer
17
+ attr_reader requests: Array[Time]
18
+ end
19
+ end
@@ -0,0 +1,28 @@
1
+ module Wikiwiki
2
+ class Wiki
3
+ attr_reader logger: Logger
4
+
5
+ def initialize: (wiki_id: String, auth: Auth::Password | Auth::ApiKey, ?rate_limiter: RateLimiter, ?logger: Logger) -> void
6
+
7
+ def url: () -> URI::HTTPS
8
+
9
+ def page_names: () -> Array[String]
10
+
11
+ def page: (page_name: String) -> Page
12
+
13
+ def update_page: (page_name: String, source: String) -> void
14
+
15
+ def attachment_names: (page_name: String) -> Array[String]
16
+
17
+ def attachment: (page_name: String, attachment_name: String, ?rev: String?) -> Attachment
18
+
19
+ def add_attachment: (page_name: String, attachment_name: String, content: String) -> void
20
+
21
+ def delete_attachment: (page_name: String, attachment_name: String) -> void
22
+
23
+ private
24
+
25
+ attr_reader api: API
26
+ attr_reader wiki_id: String
27
+ end
28
+ end
data/sig/wikiwiki.rbs ADDED
@@ -0,0 +1,24 @@
1
+ module Wikiwiki
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ class RateLimitError < Error
8
+ end
9
+
10
+ class ContentIntegrityError < Error
11
+ end
12
+
13
+ class APIError < Error
14
+ end
15
+
16
+ class AuthenticationError < APIError
17
+ end
18
+
19
+ class ResourceNotFoundError < APIError
20
+ end
21
+
22
+ class ServerError < APIError
23
+ end
24
+ end