leopard 0.2.5 → 0.2.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb81d09b4b0709f3f290009e42e9fa1080e6b33e1fa578473b789875cfa44054
4
- data.tar.gz: 0f1d6d756f9a60cb9cd9f6a7072e6c9e2fcd1060faed0121ad6a3d27496a3ea6
3
+ metadata.gz: 42beb75a4668c0874385a6d0cc8deead97def34d08a45e4c7a314ffb75697bcf
4
+ data.tar.gz: 3d69bfcf8942d34695514c9b70d25ccc68024ca4027ce3cd4cc3f22bee3b6986
5
5
  SHA512:
6
- metadata.gz: c584384a406f264f2e3bb20c0a40fd75506e7f407985f7b4fb42166899fce3b233f99df795e6e75a4c973f6369c93cb10a9eb29f4453a4cd88c19680d9839ed2
7
- data.tar.gz: 4bdbdfc0debc18a1bbd7ef8e636e4ad2019fa593fd083a47683f48bfb099302552e528eac530f99e464f5347c0475d1c49a874aa4021aaf5dcb2c4a4e321fe68
6
+ metadata.gz: 6f0b1e3f0d13fbfdee1614f5a3af3f8ba0758d5afec9cee415c7d36fc448a11e5d8c4df4895119c87ce909ff3b0b7ab61aa0956f774565930521f946f6b27053
7
+ data.tar.gz: 9cbf821b916e89d469d05abfb29ba92a337c1a08394d1a66cef39e6613426d8a2f893fbe27d55e4ebaa329f8d4ae7b50c8fa1a657df8af7fec11d9c8bec8b871
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.2.5"
2
+ ".": "0.2.6"
3
3
  }
data/.version.txt CHANGED
@@ -1 +1 @@
1
- 0.2.5
1
+ 0.2.6
data/.yardopts ADDED
@@ -0,0 +1,5 @@
1
+ --readme Readme.adoc
2
+ --protected
3
+ --private
4
+ --output-dir doc/yard
5
+ lib/**/*.rb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.6](https://github.com/rubyists/leopard/compare/v0.2.5...v0.2.6) (2026-04-21)
4
+
5
+
6
+ ### Features
7
+
8
+ * add nats_jetstream_consumer to provide the feature requested in issue [#44](https://github.com/rubyists/leopard/issues/44) ([#45](https://github.com/rubyists/leopard/issues/45)) ([9372aa4](https://github.com/rubyists/leopard/commit/9372aa4ce59b130e7143905ce5e8065519778861))
9
+
3
10
  ## [0.2.5](https://github.com/rubyists/leopard/compare/v0.2.4...v0.2.5) (2026-04-16)
4
11
 
5
12
 
data/Rakefile CHANGED
@@ -4,8 +4,15 @@ require 'rake'
4
4
  require 'minitest/test_task'
5
5
  require 'bundler/gem_tasks'
6
6
  require 'rubocop/rake_task'
7
+ require 'net/http'
8
+ require 'open3'
9
+ require 'shellwords'
10
+ require 'timeout'
11
+ require 'yard'
12
+ require 'yard/rake/yardoc_task'
7
13
 
8
14
  RuboCop::RakeTask.new
15
+ YARD::Rake::YardocTask.new(:yard)
9
16
 
10
17
  Minitest::TestTask.create(:test) do |task|
11
18
  task.libs << 'lib'
@@ -14,4 +21,106 @@ Minitest::TestTask.create(:test) do |task|
14
21
  task.warning = true
15
22
  end
16
23
 
17
- task default: %i[rubocop test]
24
+ QUICK_TEST_FILES = Dir['test/*/**/*.rb'].reject { |file| file.start_with?('test/integration/') }.sort.freeze
25
+
26
+ # Returns the local NATS JetStream health endpoint used by the CI helpers.
27
+ #
28
+ # @return [URI::HTTP] The health endpoint URI.
29
+ def nats_health_uri = URI('http://127.0.0.1:8222/healthz')
30
+
31
+ # Reports whether the local NATS JetStream health endpoint is currently reachable.
32
+ #
33
+ # @return [Boolean] `true` when the broker responds successfully, otherwise `false`.
34
+ def nats_ready?
35
+ Net::HTTP.get_response(nats_health_uri).is_a?(Net::HTTPSuccess)
36
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET
37
+ false
38
+ end
39
+
40
+ # Waits for the local NATS JetStream broker to report healthy.
41
+ #
42
+ # @return [void]
43
+ # @raise [RuntimeError] If the broker does not become healthy within 30 seconds.
44
+ def wait_for_nats!
45
+ Timeout.timeout(30) do
46
+ sleep 1 until nats_ready?
47
+ end
48
+ rescue Timeout::Error
49
+ raise 'Timed out waiting for NATS JetStream health endpoint on http://127.0.0.1:8222/healthz'
50
+ end
51
+
52
+ # Detects the container runtime used to manage the local NATS broker.
53
+ #
54
+ # @return [String] `podman` when available, otherwise `docker`.
55
+ def container_runtime
56
+ File.executable?('/usr/bin/podman') || system('command -v podman > /dev/null 2>&1', exception: false) ? 'podman' : 'docker'
57
+ end
58
+
59
+ # Runs the non-integration test files directly for a fast local feedback loop.
60
+ #
61
+ # @return [void]
62
+ def run_quick_tests!
63
+ sh "ruby -w -Ilib -Itest #{QUICK_TEST_FILES.shelljoin}"
64
+ end
65
+
66
+ # Verifies that the current YARD coverage is complete.
67
+ #
68
+ # @return [void]
69
+ # @raise [RuntimeError] If YARD reports anything less than 100% documentation coverage.
70
+ def verify_yard_coverage!
71
+ output, status = Open3.capture2e('bundle', 'exec', 'yard', 'stats', '--list-undoc')
72
+ puts output
73
+ raise 'yard stats failed' unless status.success?
74
+ return if output.include?('100.00% documented')
75
+
76
+ raise 'YARD documentation coverage is incomplete'
77
+ end
78
+
79
+ namespace :nats do
80
+ desc 'Start the local NATS JetStream broker via ./ci/nats/start.sh'
81
+ task :start do
82
+ sh({ 'NATS_DETACH' => '1' }, './ci/nats/start.sh')
83
+ end
84
+
85
+ desc 'Wait for the local NATS JetStream broker health endpoint'
86
+ task :wait do
87
+ wait_for_nats!
88
+ end
89
+
90
+ desc 'Stop the local NATS JetStream broker container'
91
+ task :stop do
92
+ name = ENV.fetch('NATS_NAME', 'leopard-nats')
93
+ sh(container_runtime, 'rm', '-f', name, verbose: false)
94
+ rescue RuntimeError
95
+ nil
96
+ end
97
+ end
98
+
99
+ namespace :ci do
100
+ desc 'Run RuboCop, YARD verification, and the non-integration test suite without managing NATS'
101
+ task quick: %i[rubocop yard:verify] do
102
+ run_quick_tests!
103
+ end
104
+
105
+ desc 'Run the full test suite against a managed local NATS JetStream broker'
106
+ task :test do
107
+ Rake::Task['nats:start'].invoke
108
+ Rake::Task['nats:wait'].invoke
109
+ Rake::Task['test'].invoke
110
+ ensure
111
+ Rake::Task['nats:stop'].reenable
112
+ Rake::Task['nats:stop'].invoke
113
+ end
114
+ end
115
+
116
+ desc 'Run RuboCop and the full test suite against a managed local NATS JetStream broker'
117
+ task ci: %w[rubocop yard:verify ci:test]
118
+
119
+ namespace :yard do
120
+ desc 'Fail if YARD reports incomplete documentation coverage'
121
+ task :verify do
122
+ verify_yard_coverage!
123
+ end
124
+ end
125
+
126
+ task default: :ci
data/Readme.adoc CHANGED
@@ -12,6 +12,7 @@ minimal DSL for defining endpoints and middleware.
12
12
  == Features
13
13
 
14
14
  * Declarative endpoint definitions with `#endpoint`.
15
+ * Declarative JetStream pull consumers with `#jetstream_endpoint`.
15
16
  * Grouping of endpoints with `#group`
16
17
  * Simple concurrency via `#run` with a configurable number of instances.
17
18
  * JSON aware message wrapper that gracefully handles parse errors.
@@ -90,13 +91,72 @@ end
90
91
  EchoService.use LoggerMiddleware
91
92
  ----
92
93
 
94
+ == JetStream Pull Consumers
95
+
96
+ Leopard can also bind JetStream pull consumers through the same middleware and `Dry::Monads::Result`
97
+ handler contract used by request/reply endpoints.
98
+
99
+ [source,ruby]
100
+ ----
101
+ class EventConsumer
102
+ include Rubyists::Leopard::NatsApiServer
103
+
104
+ jetstream_endpoint(
105
+ :events,
106
+ stream: 'EVENTS',
107
+ subject: 'events.created',
108
+ durable: 'events-created-worker',
109
+ consumer: { max_deliver: 5 },
110
+ batch: 5,
111
+ fetch_timeout: 1,
112
+ nak_delay: 2,
113
+ ) do |msg|
114
+ Success(msg.data)
115
+ end
116
+ end
117
+ ----
118
+
119
+ JetStream handlers receive the same `Rubyists::Leopard::MessageWrapper` as service endpoints.
120
+ Leopard will:
121
+
122
+ * `ack` on `Success`
123
+ * `nak` on `Failure` (`nak_delay:` is optional)
124
+ * `term` on unhandled exceptions
125
+
126
+ Each Leopard `instances:` worker creates its own pull subscription loop, so JetStream consumers
127
+ scale with the same process-local concurrency model as the rest of the framework.
128
+
93
129
  == Development
94
130
 
95
131
  The project uses Minitest and RuboCop. Run tests with Rake:
96
132
 
97
133
  [source,bash]
98
134
  ----
99
- $ bundle exec rake
135
+ $ bundle exec rake ci
136
+ ----
137
+
138
+ This task starts NATS JetStream through `./ci/nats/start.sh`, waits for broker health,
139
+ runs RuboCop and the test suite, and then stops the broker.
140
+
141
+ API documentation can be generated with:
142
+
143
+ [source,bash]
144
+ ----
145
+ $ bundle exec rake yard
146
+ ----
147
+
148
+ Documentation coverage is enforced with:
149
+
150
+ [source,bash]
151
+ ----
152
+ $ bundle exec rake yard:verify
153
+ ----
154
+
155
+ If you want to run the broker yourself, the same script can still be used directly:
156
+
157
+ [source,bash]
158
+ ----
159
+ $ ./ci/nats/start.sh
100
160
  ----
101
161
 
102
162
  === Conventional Commits (semantic commit messages)
data/ci/nats/start.sh CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
 
3
3
  NATS_VERSION=2
4
+ NATS_NAME=${NATS_NAME:-leopard-nats}
5
+ NATS_DETACH=${NATS_DETACH:-0}
4
6
 
5
7
  if readlink -f . >/dev/null 2>&1 # {{{ makes readlink work on mac
6
8
  then
@@ -29,5 +31,28 @@ else
29
31
  runtime=docker
30
32
  fi
31
33
 
34
+ args=(
35
+ run
36
+ --rm
37
+ --name "$NATS_NAME"
38
+ -p 4222:4222
39
+ -p 6222:6222
40
+ -p 8222:8222
41
+ -v ./accounts.txt:/accounts.txt
42
+ )
43
+
44
+ if [ "$NATS_DETACH" = "1" ]
45
+ then
46
+ args+=(-d)
47
+ else
48
+ args+=(-it)
49
+ fi
50
+
51
+ args+=(
52
+ "nats:$NATS_VERSION"
53
+ -js
54
+ -c /accounts.txt
55
+ )
56
+
32
57
  set -x
33
- exec "$runtime" run --rm -it -p 4222:4222 -p 6222:6222 -p 8222:8222 -v ./accounts.txt:/accounts.txt nats:"$NATS_VERSION" -js -c /accounts.txt "$@"
58
+ exec "$runtime" "${args[@]}" "$@"
@@ -8,6 +8,12 @@ class EchoService
8
8
  include Rubyists::Leopard::NatsApiServer
9
9
 
10
10
  config.logger = SemanticLogger[:EchoService]
11
+
12
+ # Builds the example service instance.
13
+ #
14
+ # @param a_var [Integer] Example initializer state passed through `instance_args`.
15
+ #
16
+ # @return [void]
11
17
  def initialize(a_var: 1)
12
18
  logger.info "EchoService initialized with a_var: #{a_var}"
13
19
  end
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/leopard/nats_api_server'
5
+
6
+ # Example JetStream worker for async event processing.
7
+ class EventConsumer
8
+ include Rubyists::Leopard::NatsApiServer
9
+
10
+ config.logger = SemanticLogger[:EventConsumer]
11
+
12
+ jetstream_endpoint(
13
+ :events,
14
+ stream: 'EVENTS',
15
+ subject: 'events.created',
16
+ durable: 'events-created-worker',
17
+ consumer: {
18
+ ack_wait: 30,
19
+ max_deliver: 5,
20
+ },
21
+ batch: 5,
22
+ fetch_timeout: 1,
23
+ nak_delay: 2,
24
+ ) do |msg|
25
+ logger.info 'Processing event', data: msg.data
26
+ Success(msg.data)
27
+ rescue StandardError => e
28
+ Failure(error: e.message, data: msg.data)
29
+ end
30
+ end
31
+
32
+ if __FILE__ == $PROGRAM_NAME
33
+ SemanticLogger.default_level = :info
34
+ SemanticLogger.add_signal_handler
35
+ SemanticLogger.add_appender(io: $stdout, formatter: :color)
36
+ EventConsumer.run(
37
+ nats_url: 'nats://localhost:4222',
38
+ service_opts: {
39
+ name: 'example.event_consumer',
40
+ version: '1.0.0',
41
+ },
42
+ instances: ENV.fetch('EVENT_CONSUMER_INSTANCES', '1').to_i,
43
+ )
44
+ end
@@ -2,12 +2,19 @@
2
2
 
3
3
  module Rubyists
4
4
  module Leopard
5
+ # Base Leopard exception that truncates backtraces for cleaner logs.
5
6
  class LeopardError < StandardError
7
+ # Captures the original exception state while replacing the backtrace with the current call stack.
8
+ #
9
+ # @return [void]
6
10
  def initialize(...)
7
11
  super
8
12
  set_backtrace(caller)
9
13
  end
10
14
 
15
+ # Returns a Leopard-truncated backtrace.
16
+ #
17
+ # @return [Array<String>] Up to the first four backtrace entries, plus a truncation marker when applicable.
11
18
  def backtrace
12
19
  # If the backtrace is nil, return an empty array
13
20
  orig = (super || [])[0..3]
@@ -19,8 +26,11 @@ module Rubyists
19
26
  end
20
27
  end
21
28
 
29
+ # Generic Leopard error superclass.
22
30
  class Error < LeopardError; end
31
+ # Raised when Leopard configuration is invalid.
23
32
  class ConfigurationError < Error; end
33
+ # Raised when a handler returns an unsupported object instead of a result monad.
24
34
  class ResultError < Error; end
25
35
  end
26
36
  end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyists
4
+ module Leopard
5
+ # Composes middleware around Leopard handlers and routes their results to transport callbacks.
6
+ class MessageProcessor
7
+ private attr_reader :execute_handler, :logger, :middleware, :wrapper_factory
8
+
9
+ # Builds a reusable processor for request/reply and JetStream transports.
10
+ #
11
+ # @param wrapper_factory [#call] Callable that wraps a raw transport message in a {MessageWrapper}-compatible object.
12
+ # @param middleware [#call] Callable returning the current middleware stack.
13
+ # @param execute_handler [#call] Callable that executes the endpoint handler with the wrapped message.
14
+ # @param logger [#error] Logger used for processing failures.
15
+ #
16
+ # @return [void]
17
+ def initialize(wrapper_factory:, middleware:, execute_handler:, logger:)
18
+ @wrapper_factory = wrapper_factory
19
+ @middleware = middleware
20
+ @execute_handler = execute_handler
21
+ @logger = logger
22
+ end
23
+
24
+ # Processes a raw transport message through middleware and terminal callbacks.
25
+ #
26
+ # @param raw_msg [Object] The raw transport message from NATS.
27
+ # @param handler [Proc] The endpoint handler to execute.
28
+ # @param callbacks [Hash{Symbol => #call}] Success, failure, and error callbacks for the transport.
29
+ #
30
+ # @return [Object] The transport-specific callback result.
31
+ def process(raw_msg, handler, callbacks)
32
+ app(callbacks, handler).call(wrapper_factory.call(raw_msg))
33
+ end
34
+
35
+ private
36
+
37
+ # Builds the middleware stack around the terminal application.
38
+ #
39
+ # @param callbacks [Hash{Symbol => #call}] Transport callbacks keyed by outcome.
40
+ # @param handler [Proc] The endpoint handler to execute at the core of the stack.
41
+ #
42
+ # @return [#call] The composed middleware application.
43
+ def app(callbacks, handler)
44
+ middleware.call.reverse_each.reduce(base_app(handler, callbacks)) do |current, (klass, args, blk)|
45
+ klass.new(current, *args, &blk)
46
+ end
47
+ end
48
+
49
+ # Builds the terminal application that runs the handler and dispatches transport callbacks.
50
+ #
51
+ # @param handler [Proc] The endpoint handler to execute.
52
+ # @param callbacks [Hash{Symbol => #call}] Transport callbacks keyed by outcome.
53
+ #
54
+ # @return [Proc] The terminal application for the middleware chain.
55
+ def base_app(handler, callbacks)
56
+ lambda do |wrapper|
57
+ result = execute_handler.call(wrapper, handler)
58
+ process_result(wrapper, result, callbacks)
59
+ rescue StandardError => e
60
+ logger.error 'Error processing message: ', e
61
+ callbacks[:on_error].call(wrapper, e)
62
+ end
63
+ end
64
+
65
+ # Routes a {Dry::Monads::Result} to the appropriate transport callback.
66
+ #
67
+ # @param wrapper [MessageWrapper] The wrapped transport message.
68
+ # @param result [Dry::Monads::Result] The handler result to route.
69
+ # @param callbacks [Hash{Symbol => #call}] Transport callbacks keyed by outcome.
70
+ #
71
+ # @return [Object] The callback return value for the routed result.
72
+ # @raise [ResultError] If the handler returned a non-result object.
73
+ def process_result(wrapper, result, callbacks)
74
+ case result
75
+ in Dry::Monads::Success
76
+ callbacks[:on_success].call(wrapper, result)
77
+ in Dry::Monads::Failure
78
+ callbacks[:on_failure].call(wrapper, result)
79
+ else
80
+ logger.error('Unexpected result: ', result:)
81
+ raise ResultError, "Unexpected Response from Handler, must respond with a Success or Failure monad: #{result}"
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -4,15 +4,19 @@ require 'json'
4
4
 
5
5
  module Rubyists
6
6
  module Leopard
7
+ # Wraps a raw NATS message with parsed payload and convenience response helpers.
7
8
  class MessageWrapper
8
9
  # @!attribute [r] raw
10
+ #
9
11
  # @return [NATS::Message] The original NATS message.
10
12
  #
11
13
  # @!attribute [r] data
14
+ #
12
15
  # @return [Object] The parsed data from the NATS message.
13
16
  attr_reader :raw, :data
14
17
  #
15
18
  # @!attribute [w] headers
19
+ #
16
20
  # @return [Hash] The headers from the NATS message.
17
21
  attr_accessor :headers
18
22
 
@@ -5,9 +5,15 @@ require 'erb'
5
5
 
6
6
  module Rubyists
7
7
  module Leopard
8
+ # Adds a minimal Prometheus HTTP endpoint for Leopard worker metrics.
8
9
  module MetricsServer
9
10
  private
10
11
 
12
+ # Starts a lightweight HTTP server that exposes Leopard Prometheus metrics.
13
+ #
14
+ # @param workers [Array<Object>] Active Leopard worker instances to observe.
15
+ #
16
+ # @return [Thread] The server thread.
11
17
  def start_metrics_server(workers)
12
18
  port = ENV.fetch('LEOPARD_METRICS_PORT', '9394').to_i
13
19
  Thread.new do
@@ -19,6 +25,12 @@ module Rubyists
19
25
  end
20
26
  end
21
27
 
28
+ # Handles an individual metrics HTTP client connection.
29
+ #
30
+ # @param client [TCPSocket] The connected HTTP client.
31
+ # @param workers [Array<Object>] Active Leopard worker instances to observe.
32
+ #
33
+ # @return [void]
22
34
  def handle_metrics_client(client, workers)
23
35
  request_line = client.gets
24
36
  loop { break if (client.gets || '').chomp.empty? }
@@ -29,12 +41,24 @@ module Rubyists
29
41
  close_client(client)
30
42
  end
31
43
 
44
+ # Closes a metrics client socket, ignoring cleanup failures.
45
+ #
46
+ # @param client [TCPSocket] The connected HTTP client.
47
+ #
48
+ # @return [void]
32
49
  def close_client(client)
33
50
  client.close
34
51
  rescue StandardError
35
52
  nil
36
53
  end
37
54
 
55
+ # Writes the HTTP response for a metrics request.
56
+ #
57
+ # @param client [TCPSocket] The connected HTTP client.
58
+ # @param request_line [String, nil] The first line of the HTTP request.
59
+ # @param workers [Array<Object>] Active Leopard worker instances to observe.
60
+ #
61
+ # @return [void]
38
62
  def write_metrics_response(client, request_line, workers)
39
63
  if request_line&.start_with?('GET /metrics')
40
64
  body = prometheus_metrics(workers)
@@ -46,11 +70,21 @@ module Rubyists
46
70
  end
47
71
  end
48
72
 
73
+ # Builds the Prometheus metrics payload for the current worker state.
74
+ #
75
+ # @param workers [Array<Object>] Active Leopard worker instances to observe.
76
+ #
77
+ # @return [String] Rendered Prometheus text exposition output.
49
78
  def prometheus_metrics(workers)
50
79
  metrics = collect_prometheus_metrics(workers)
51
80
  render_metrics_template(metrics)
52
81
  end
53
82
 
83
+ # Aggregates per-subject worker utilization metrics.
84
+ #
85
+ # @param workers [Array<Object>] Active Leopard worker instances to observe.
86
+ #
87
+ # @return [Hash{Symbol => Object}] Metric hashes for the Prometheus template.
54
88
  def collect_prometheus_metrics(workers)
55
89
  busy = Hash.new(0)
56
90
  pending = Hash.new(0)
@@ -63,6 +97,13 @@ module Rubyists
63
97
  }
64
98
  end
65
99
 
100
+ # Adds one worker's endpoint saturation metrics to the aggregate hashes.
101
+ #
102
+ # @param worker [Object] A Leopard worker instance.
103
+ # @param busy [Hash{String => Integer}] Subject-to-busy-worker counts.
104
+ # @param pending [Hash{String => Integer}] Subject-to-pending-message counts.
105
+ #
106
+ # @return [void]
66
107
  def accumulate_worker_metrics(worker, busy, pending)
67
108
  service = worker.instance_variable_get(:@service)
68
109
  return unless service
@@ -78,10 +119,18 @@ module Rubyists
78
119
  end
79
120
  end
80
121
 
122
+ # Renders the metrics ERB template with aggregated metric data.
123
+ #
124
+ # @param metrics [Hash{Symbol => Object}] Aggregated metric data for template rendering.
125
+ #
126
+ # @return [String] The rendered Prometheus payload.
81
127
  def render_metrics_template(metrics)
82
128
  ERB.new(File.read(metrics_template_path), trim_mode: '-').result_with_hash(metrics)
83
129
  end
84
130
 
131
+ # Returns the absolute path to the Prometheus metrics template.
132
+ #
133
+ # @return [String] The metrics template path.
85
134
  def metrics_template_path
86
135
  File.expand_path('templates/prometheus_metrics.erb', __dir__)
87
136
  end