leopard 0.1.0 → 0.1.3

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: 925e3c42a3a80193197765a65c4fa5301961310167cfc053e20c936c8a4e7054
4
- data.tar.gz: f3e36504a2f3586f56f50b2f6bc4a25e6657b696b9f254e61b5baf33d20695cf
3
+ metadata.gz: a1e36e2390cc27892053da1e1e37648f5b9d525ea45490dbce1388073c0dd909
4
+ data.tar.gz: 4696ea5b7be68a9f9b7700f457240e398cfb20cd26f62ab6f2a196ab37a92215
5
5
  SHA512:
6
- metadata.gz: 4987b6747903a013100ac5e8535688d4c7298d0f520cfcc81e207351c116c4cf9b77d89b07d09bc67714a2d2ed275aa5f898d519c0413fe66fac6974d878b016
7
- data.tar.gz: ac0484d1a9267faae2daf88cdb4f8e693c0e6399abaf979e8e32e0e77b15e7d7bb32d4b39af4f72c685a9bce3cee5986a851836e309cb37b262b4d44b5a54c84
6
+ metadata.gz: 6dc17020cfdae2aadac7620feef13b52f9c362d6fdf2dfdc2ea16f52a9bfbca6538317c647f6ad000a21c6f9ca8279e2c7116143db4907d055239f19a3da369e
7
+ data.tar.gz: fd5ff2f5041715540adc05b8939e64fb5f5a7efca1d207a0c6144a0f608f2869c58034de4551fe3c55f2db756b17c607dde9afd20155145933662806952489c4
@@ -0,0 +1,32 @@
1
+ {
2
+ "packages": {
3
+ ".": {
4
+ "changelog-path": "CHANGELOG.md",
5
+ "release-type": "simple",
6
+ "bump-minor-pre-major": true,
7
+ "bump-patch-for-minor-pre-major": true,
8
+ "draft": false,
9
+ "prerelease": false,
10
+ "version-file": ".version.txt",
11
+ "extra-files": [
12
+ {
13
+ "type": "generic",
14
+ "path": "lib/leopard/version.rb"
15
+ },
16
+ {
17
+ "type": "generic",
18
+ "path": "oci/Gemfile"
19
+ }
20
+ ],
21
+ "exclude-paths": [
22
+ ".release-please-manifest.json",
23
+ ".version.txt",
24
+ "lib/leopard/version.rb",
25
+ ".rubocop.yml",
26
+ ".overcommit.yml",
27
+ "coverage/coverage.json"
28
+ ]
29
+ }
30
+ },
31
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
32
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.1.3"
3
+ }
data/.rubocop.yml ADDED
@@ -0,0 +1,46 @@
1
+ plugins:
2
+ - rubocop-performance
3
+ - rubocop-rake
4
+ - rubocop-minitest
5
+
6
+ AllCops:
7
+ NewCops: enable
8
+
9
+ Layout/LineLength:
10
+ Max: 140
11
+
12
+ Metrics/BlockLength:
13
+ Exclude:
14
+ - 'spec/**/*.rb'
15
+
16
+ Layout/ArgumentAlignment:
17
+ EnforcedStyle: with_fixed_indentation
18
+
19
+ Bundler/OrderedGems:
20
+ TreatCommentsAsGroupSeparators: true
21
+
22
+ Layout/MultilineMethodCallIndentation:
23
+ EnforcedStyle: indented
24
+
25
+ Layout/ArrayAlignment:
26
+ EnforcedStyle: with_fixed_indentation
27
+
28
+ Style/Documentation:
29
+ Enabled: false
30
+
31
+ Style/FrozenStringLiteralComment:
32
+ Enabled: true
33
+ SafeAutoCorrect: true
34
+
35
+ Style/TrailingCommaInArguments:
36
+ EnforcedStyleForMultiline: comma
37
+
38
+ Style/TrailingCommaInArrayLiteral:
39
+ EnforcedStyleForMultiline: comma
40
+
41
+ Style/TrailingCommaInHashLiteral:
42
+ EnforcedStyleForMultiline: comma
43
+
44
+ Style/HashSyntax:
45
+ Enabled: true
46
+ EnforcedShorthandSyntax: always
data/.version.txt ADDED
@@ -0,0 +1 @@
1
+ 0.1.3
data/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ ## [0.1.3](https://github.com/rubyists/leopard/compare/v0.1.2...v0.1.3) (2025-07-31)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * Fixes the gem publisher, and adds missing .version.txt ([#11](https://github.com/rubyists/leopard/issues/11)) ([3dcbd3c](https://github.com/rubyists/leopard/commit/3dcbd3c1d687e04ce5fde85fef5c2d1c10a8a4cc))
9
+
10
+ ## [0.1.2](https://github.com/rubyists/leopard/compare/v0.1.1...v0.1.2) (2025-07-31)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * Remove sequel cruft in ci config ([#9](https://github.com/rubyists/leopard/issues/9)) ([09a43e2](https://github.com/rubyists/leopard/commit/09a43e23c309167c56095dd608af9d79ff4f9b19))
16
+
17
+ ## [0.1.1](https://github.com/rubyists/leopard/compare/v0.1.0...v0.1.1) (2025-07-31)
18
+
19
+
20
+ ### Features
21
+
22
+ * Adds gemspec and gemfile ([#1](https://github.com/rubyists/leopard/issues/1)) ([972dc72](https://github.com/rubyists/leopard/commit/972dc72de804ca10db5cf869d0ea996a94ac9722))
23
+ * Adds rakefile back ([#3](https://github.com/rubyists/leopard/issues/3)) ([271592c](https://github.com/rubyists/leopard/commit/271592c357e07d58de085297850533eaae60a285))
24
+ * Adds settings to module ([9ff0942](https://github.com/rubyists/leopard/commit/9ff0942dddd86bf4f97bc82626cc7bb35e4115ac))
25
+ * Basic functionality for serving apis ([#4](https://github.com/rubyists/leopard/issues/4)) ([9ff0942](https://github.com/rubyists/leopard/commit/9ff0942dddd86bf4f97bc82626cc7bb35e4115ac))
26
+ * Initial readme ([4ea9f34](https://github.com/rubyists/leopard/commit/4ea9f341c9df6096b8df3595ff6a075eb9b5c4f6))
27
+
28
+
29
+ ### Bug Fixes
30
+
31
+ * Corrects gemname in publish-gem.sh ([9ff0942](https://github.com/rubyists/leopard/commit/9ff0942dddd86bf4f97bc82626cc7bb35e4115ac))
32
+ * Corrects the version ([#7](https://github.com/rubyists/leopard/issues/7)) ([a3de532](https://github.com/rubyists/leopard/commit/a3de5320a8c54e9ca6724b6e90812bb5b1b7d150))
data/Rakefile ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+ require 'minitest/test_task'
5
+ require 'bundler/gem_tasks'
6
+ require 'rubocop/rake_task'
7
+
8
+ RuboCop::RakeTask.new
9
+
10
+ Minitest::TestTask.create(:test) do |task|
11
+ task.libs << 'lib'
12
+ task.libs << 'test'
13
+ task.test_globs = ['test/*/**/*.rb']
14
+ task.warning = true
15
+ end
16
+
17
+ task default: %i[rubocop test]
data/Readme.adoc CHANGED
@@ -1,9 +1,111 @@
1
- # Leopard Nats ServiceApi Server
1
+ = Leopard NATS ServiceApi Server
2
2
  bougyman <me@bougyman.com>
3
- :service-api: https://github.com/rubyists/nats-pure.rb/blob/main/docs/service_api.md[Service API]
3
+ :service-api: https://github.com/rubyists/nats-pure.rb/blob/main/docs/service_api.md[NATS Service API]
4
+ :conventional-commits: https://www.conventionalcommits.org/en/v1.0.0/[Conventional Commits]
5
+ :dry-configurable: https://github.com/dry-rb/dry-configurable[Dry::Configurable]
6
+ :dry-monads: https://github.com/dry-rb/dry-monads[Dry::Monads]
4
7
 
5
- The leopard nats serviceapi server provides a simple concurrency
6
- model for NATS {service-api} workers. It is designed to be used
7
- similarly to a web server (inspired by puma), defining endpoints
8
- in your classes, and then serving them via the leopard (Ractor-based)
9
- service supervisor.
8
+ Leopard is a small framework for building concurrent {service-api} workers.
9
+ It uses `Concurrent::FixedThreadPool` to manage multiple workers in a single process and provides a
10
+ minimal DSL for defining endpoints and middleware.
11
+
12
+ == Features
13
+
14
+ * Declarative endpoint definitions with `#endpoint`.
15
+ * Grouping of endpoints with `#group`
16
+ * Simple concurrency via `#run` with a configurable number of instances.
17
+ * JSON aware message wrapper that gracefully handles parse errors.
18
+ * Middleware support using `#use`.
19
+ * Railway Oriented Design, using {dry-monads} for success and failure handling.
20
+ * {dry-configurable} settings container.
21
+ * `#logger` defaults to SemanticLogger (adjustable as the `#logger=` setting)
22
+
23
+ == Requirements
24
+
25
+ * Ruby >= 3.4.0
26
+ * A running NATS server with the Service API enabled.
27
+
28
+ == Installation
29
+
30
+ Add the gem to your project:
31
+
32
+ [source,ruby]
33
+ ----
34
+ # Gemfile
35
+ gem 'leopard'
36
+ ----
37
+
38
+ Then install it with Bundler.
39
+
40
+ [source,bash]
41
+ ----
42
+ $ bundle install
43
+ ----
44
+
45
+ == Usage
46
+
47
+ Create a service class and include `Rubyists::Leopard::NatsApiServer`.
48
+ Define one or more endpoints. Each endpoint receives a
49
+ `Rubyists::Leopard::MessageWrapper` object for each request to the {service-api} endpoint
50
+ that service class is is subscribed to (subject:, or name:). The message handler/callback
51
+ is expected to return a `Dry::Monads[:result]` object, typically a `Success` or `Failure`.
52
+
53
+ [source,ruby]
54
+ ----
55
+ class EchoService
56
+ include Rubyists::Leopard::NatsApiServer
57
+
58
+ endpoint :echo do |msg|
59
+ Success(msg.data)
60
+ end
61
+ end
62
+ ----
63
+
64
+ Run the service by providing the NATS connection details and service options:
65
+
66
+ [source,ruby]
67
+ ----
68
+ EchoService.run(
69
+ nats_url: 'nats://localhost:4222',
70
+ service_opts: { name: 'echo' },
71
+ instances: 4
72
+ )
73
+ ----
74
+
75
+ Middleware can be inserted around endpoint dispatch:
76
+
77
+ [source,ruby]
78
+ ----
79
+ class LoggerMiddleware
80
+ def initialize(app)
81
+ @app = app
82
+ end
83
+
84
+ def call(wrapper)
85
+ puts "received: #{wrapper.data.inspect}"
86
+ @app.call(wrapper)
87
+ end
88
+ end
89
+
90
+ EchoService.use LoggerMiddleware
91
+ ----
92
+
93
+ == Development
94
+
95
+ The project uses Minitest and RuboCop. Run tests with Rake:
96
+
97
+ [source,bash]
98
+ ----
99
+ $ bundle exec rake
100
+ ----
101
+
102
+ === Conventional Commits (semantic commit messages)
103
+
104
+ This project follows the {conventional-commits} specification.
105
+
106
+ To contribute, please follow that commit message format,
107
+ or your pull request may be rejected.
108
+
109
+ == License
110
+
111
+ MIT
data/ci/build_image.sh ADDED
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env bash
2
+
3
+ if readlink -f . >/dev/null 2>&1 # {{{ makes readlink work on mac
4
+ then
5
+ readlink=readlink
6
+ else
7
+ if greadlink -f . >/dev/null 2>&1
8
+ then
9
+ readlink=greadlink
10
+ else
11
+ printf "You must install greadlink to use this (brew install coreutils)\n" >&2
12
+ fi
13
+ fi # }}}
14
+
15
+ # Set here to the full path to this script
16
+ me=${BASH_SOURCE[0]}
17
+ [ -L "$me" ] && me=$($readlink -f "$me")
18
+ here=$(cd "$(dirname "$me")" && pwd)
19
+ just_me=$(basename "$me")
20
+
21
+ repo_top=$(git rev-parse --show-toplevel)
22
+ cd "$repo_top" || {
23
+ printf "Could not cd to %s\n" "$repo_top" >&2
24
+ exit 1
25
+ }
26
+
27
+ base_dir=$(basename "$(pwd)")
28
+ : "${UBUNTU_VERSION:=bookworm}"
29
+ : "${BUILD_CONTEXT:=$(pwd)}"
30
+ : "${IMAGE_NAME:=$base_dir}"
31
+ : "${LICENSE:=MIT}"
32
+ : "${REGISTRY:=ghcr.io}"
33
+ : "${RUBY_VERSION:=3.3.6}"
34
+ : "${REGISTRY_TOKEN:=$GITHUB_TOKEN}"
35
+
36
+ base_image_tag="$RUBY_VERSION-$UBUNTU_VERSION"
37
+ base_exists=$(skopeo list-tags docker://docker.io/ruby |jq -r "any(.Tags[] == \"$base_image_tag\"; .)")
38
+ if [ "$base_exists" = "false" ]
39
+ then
40
+ printf "Base image %s does not exist at docker.io/ruby, cannot build.\n" "$base_image_tag" >&2
41
+ exit 99
42
+ fi
43
+
44
+ usage() { # {{{
45
+ cat <<-EOT
46
+ Build an image, optionally pushing it to the registry
47
+
48
+ Usage: $0 <options> <image_tag>
49
+ Options:
50
+ -c CONTAINERFILE Path to the containerfile (default: ./oci/Containerfile)
51
+ -C CONTEXT Build context (default: $BUILD_CONTEXT)
52
+ -i NAME Name of the image (default: $IMAGE_NAME)
53
+ -l LICENSE License of the image (default: $LICENSE)
54
+ -r REGISTRY Registry to push the image to when -p is given (default: $REGISTRY)
55
+ -p Push the image to the registry
56
+ -h Show help / usage
57
+ EOT
58
+ } # }}}
59
+
60
+ die() { # {{{
61
+ local -i code
62
+ code=$1
63
+ shift
64
+ error "$*"
65
+ printf "\n" >&2
66
+ usage >&2
67
+ # shellcheck disable=SC2086
68
+ exit $code
69
+ } # }}}
70
+
71
+ ## Logging functions # {{{
72
+ log() { # {{{
73
+ printf "%s [%s] <%s> %s\n" "$(date '+%Y-%m-%d %H:%M:%S.%6N')" "$$" "${just_me:-$0}" "$*"
74
+ } # }}}
75
+
76
+ debug() { # {{{
77
+ [ $verbose -lt 2 ] && return 0
78
+ # shellcheck disable=SC2059
79
+ log_line=$(printf "$@")
80
+ log "[DEBUG] $log_line" >&2
81
+ } # }}}
82
+
83
+ warn() { # {{{
84
+ # shellcheck disable=SC2059
85
+ log_line=$(printf "$@")
86
+ log "[WARN] $log_line" >&2
87
+ } # }}}
88
+
89
+ error() { # {{{
90
+ # shellcheck disable=SC2059
91
+ log_line=$(printf "$@")
92
+ log "[ERROR] $log_line" >&2
93
+ } # }}}
94
+
95
+ info() { # {{{
96
+ [ $verbose -lt 1 ] && return 0
97
+ # shellcheck disable=SC2059
98
+ log_line=$(printf "$@")
99
+ log "[INFO] $log_line" >&2
100
+ } # }}}
101
+ # }}}
102
+
103
+ push=0
104
+ verbose=0
105
+ while getopts :hpvc:C:i:l:r: opt # {{{
106
+ do
107
+ case $opt in
108
+ c)
109
+ CONTAINERFILE=$OPTARG
110
+ ;;
111
+ C)
112
+ BUILD_CONTEXT=$OPTARG
113
+ ;;
114
+ i)
115
+ IMAGE_NAME=$OPTARG
116
+ ;;
117
+ l)
118
+ LICENSE=$OPTARG
119
+ ;;
120
+ r)
121
+ REGISTRY=$OPTARG
122
+ ;;
123
+ p)
124
+ push=1
125
+ ;;
126
+ v)
127
+ verbose=$((verbose + 1))
128
+ ;;
129
+ h)
130
+ usage
131
+ exit
132
+ ;;
133
+ :)
134
+ printf "Option %s requires an argument\n" "$OPTARG" >&2
135
+ usage >&2
136
+ exit 28
137
+ ;;
138
+ ?)
139
+ printf "Invalid option '%s'\n" "$OPTARG" >&2
140
+ usage >&2
141
+ exit 27
142
+ ;;
143
+ esac
144
+ done # }}}
145
+ shift $((OPTIND-1))
146
+
147
+ tag=$1
148
+ [ -z "$tag" ] && die 1 "Missing image tag"
149
+ shift
150
+
151
+ # Check for extra argument
152
+ if [ $# -gt 0 ]; then
153
+ # If we have the special argument '--' we shift it away, otherwise we die
154
+ [ "$1" != '--' ] && die 2 "Too many arguments"
155
+ # Once this is shifted away, the rest of the arguments are passed to the build command, below
156
+ shift
157
+ fi
158
+
159
+ if [ -z "$CONTAINERFILE" ]; then
160
+ printf "No containerfile specified, looking for default locations\n"
161
+ for containerfile in Containerfile Dockerfile
162
+ do
163
+ if [ -f ./oci/"$containerfile" ]; then
164
+ debug "Found ./oci/%s\n" "$containerfile"
165
+ containerfile=./oci/"$containerfile"
166
+ break
167
+ fi
168
+ if [ -f "$containerfile" ]; then
169
+ debug "Found %s\n" "$containerfile"
170
+ break
171
+ fi
172
+ done
173
+ else
174
+ [ -f "$CONTAINERFILE" ] || die 3 "Containerfile '$CONTAINERFILE' not found"
175
+ debug "Using containerfile %s\n" "$CONTAINERFILE"
176
+ containerfile=$CONTAINERFILE
177
+ fi
178
+
179
+ [ -f "$containerfile" ] || die 4 "No containerfile found"
180
+
181
+ [ -d "$BUILD_CONTEXT" ] || die 5 "Build context '$BUILD_CONTEXT' not found"
182
+
183
+ debug 'Building image from %s in in %s\n' "$containerfile" "$here"
184
+ # Build the image
185
+ if command -v podman 2>/dev/null
186
+ then
187
+ runtime=podman
188
+ elif command -v docker 2>/dev/null
189
+ then
190
+ runtime=docker
191
+ else
192
+ die 6 "No container runtime found"
193
+ fi
194
+
195
+ revision=$(git rev-parse HEAD)
196
+ shortref=$(git rev-parse --short "$revision")
197
+ repo_url=$(git remote get-url origin)
198
+ if [ -z "$repo_url" ]
199
+ then
200
+ die 7 "No remote found"
201
+ fi
202
+ if [[ $repo_url == *github.com/* ]]
203
+ then
204
+ owner_and_repo=${repo_url#*github.com/}
205
+ else
206
+ owner_and_repo=${repo_url##*:}
207
+ fi
208
+ # Get rid of the trailing .git
209
+ service=$(basename "$owner_and_repo" .git)
210
+ owner=$(dirname "$owner_and_repo")
211
+
212
+ full_tag=$IMAGE_NAME:$tag
213
+ # Pass any extra arguments to the build command ("$@" contains the rest of the arguments)
214
+ $runtime build --tag "$full_tag" "$@" \
215
+ --label org.opencontainers.image.created="$(date --utc --iso-8601=seconds)" \
216
+ --label org.opencontainers.image.description="Image for $service" \
217
+ --label org.opencontainers.image.licenses="$LICENSE" \
218
+ --label org.opencontainers.image.revision="$revision" \
219
+ --label org.opencontainers.image.url="$repo_url" \
220
+ --label org.opencontainers.image.title="$IMAGE_NAME" \
221
+ --label org.opencontainers.image.source="Generated by ruby-automation's build_image.sh ($USER@$HOSTNAME)" \
222
+ --label org.opencontainers.image.version="$full_tag" \
223
+ --label shortref="$shortref" \
224
+ --build-arg UBUNTU_VERSION="$UBUNTU_VERSION" \
225
+ --build-arg RUBY_VERSION="$RUBY_VERSION" \
226
+ -f "$containerfile" "$BUILD_CONTEXT" || die 8 "Failed to build image"
227
+
228
+ [ $push -eq 1 ] || exit 0
229
+ if ! $runtime login --get-login "$REGISTRY" >/dev/null 2>/dev/null
230
+ then
231
+ printf "Not logged in to '%s', trying to login\n" "$REGISTRY" >&2
232
+ [ -z "$REGISTRY_TOKEN" ] && die 9 "No REGISTRY_TOKEN (nor GITHUB_TOKEN) set, cannot login"
233
+ printf "%s" "$REGISTRY_TOKEN" | $runtime login -u "$REGISTRY_TOKEN" --password-stdin "$REGISTRY" || die 10 "Failed to login to $REGISTRY"
234
+ fi
235
+
236
+ # Split 1.2.3 into 1.2.3, 1.2, 1. We want to tag our image with all 3 of these
237
+ mapfile -t tags < <(echo "$tag" | awk -F'.' 'NF==3{print; print $1"."$2; print $1; next} NF==2{print; print $1; next} {print}')
238
+ for t in "${tags[@]}"
239
+ do
240
+ new_tag=$IMAGE_NAME:$t
241
+ registry_image_name="$REGISTRY/$owner/$new_tag"
242
+ if [ "$runtime" = "podman" ]
243
+ then
244
+ if [ "$full_tag" != "$new_tag" ]
245
+ then
246
+ debug "Tagging %s as %s\n" "$full_tag" "$new_tag"
247
+ podman tag "$full_tag" "$new_tag" || die 11 "Failed to tag image $full_tag as $new_tag"
248
+ fi
249
+ podman push "$new_tag" "$registry_image_name" || die 12 "Failed to push image $new_tag to $registry_image_name"
250
+ else
251
+ debug "Tagging %s as %s\n" "$full_tag" "$registry_image_name"
252
+ docker tag "$full_tag" "$registry_image_name" || die 13 "Failed to tag image $full_tag as $registry_image_name"
253
+ docker push "$registry_image_name" || die 14 "Failed to push image $new_tag to $registry_image_name"
254
+ fi
255
+ done
256
+
257
+ # vim: set foldmethod=marker et ts=4 sts=4 sw=4 ft=bash :
@@ -0,0 +1,27 @@
1
+ # Client port of 4222 on all interfaces
2
+ port: 4222
3
+
4
+ # HTTP monitoring port
5
+ monitor_port: 8222
6
+
7
+ accounts: {
8
+ $SYS: {
9
+ users: [
10
+ { user: sys, password: sys }
11
+ ]
12
+ }
13
+ ME: {
14
+ jetstream: enabled
15
+ users: [
16
+ { user: me, password: youandme }
17
+ ]
18
+ }
19
+ }
20
+ no_auth_user: me
21
+
22
+ authorization {
23
+ default_permissions = {
24
+ publish = ">"
25
+ subscribe = ">"
26
+ }
27
+ }
data/ci/nats/start.sh ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bash
2
+
3
+ NATS_VERSION=2
4
+
5
+ if readlink -f . >/dev/null 2>&1 # {{{ makes readlink work on mac
6
+ then
7
+ readlink=readlink
8
+ else
9
+ if greadlink -f . >/dev/null 2>&1
10
+ then
11
+ readlink=greadlink
12
+ else
13
+ printf "You must install greadlink to use this (brew install coreutils)\n" >&2
14
+ fi
15
+ fi # }}}
16
+
17
+ # Set here to the full path to this script
18
+ me=${BASH_SOURCE[0]}
19
+ [ -L "$me" ] && me=$($readlink -f "$me")
20
+ here=$(cd "$(dirname "$me")" && pwd)
21
+ just_me=$(basename "$me")
22
+ export just_me
23
+
24
+ cd "$here" || exit 1
25
+ if command -v podman 2>/dev/null
26
+ then
27
+ runtime=podman
28
+ else
29
+ runtime=docker
30
+ fi
31
+
32
+ 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 "$@"
data/ci/publish-gem.sh ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bash
2
+
3
+ if readlink -f . >/dev/null 2>&1 # {{{ makes readlink work on mac
4
+ then
5
+ readlink=readlink
6
+ else
7
+ if greadlink -f . >/dev/null 2>&1
8
+ then
9
+ readlink=greadlink
10
+ else
11
+ printf "You must install greadlink to use this (brew install coreutils)\n" >&2
12
+ fi
13
+ fi # }}}
14
+
15
+ # Set here to the full path to this script
16
+ me=${BASH_SOURCE[0]}
17
+ [ -L "$me" ] && me=$($readlink -f "$me")
18
+ here=$(cd "$(dirname "$me")" && pwd)
19
+ root=$(cd "$here/.." && pwd)
20
+ just_me=$(basename "$me")
21
+
22
+ : "${GEM_NAME:=leopard}"
23
+ : "${GIT_ORG:=rubyists}"
24
+
25
+ GEM_HOST=$1
26
+ : "${GEM_HOST:=rubygems}"
27
+
28
+ case "$GEM_HOST" in
29
+ rubygems)
30
+ gem_key='rubygems'
31
+ gem_host='https://rubygems.org'
32
+ ;;
33
+ github)
34
+ gem_key='github'
35
+ gem_host="https://rubygems.pkg.github.com/$GIT_ORG"
36
+ # Replace the gem host in the gemspec, so it allows pushing to the GitHub package registry
37
+ sed --in-place=.bak -e "s|https://rubygems.org|https://rubygems.pkg.github.com/$GIT_ORG|" "$here/../$GEM_NAME".gemspec
38
+ # Restore the original gemspec after the script finishes
39
+ trap 'mv -v "$here/../$GEM_NAME".gemspec.bak "$here/../$GEM_NAME".gemspec' EXIT
40
+ ;;
41
+ *)
42
+ printf 'Unknown GEM_HOST: %s\n' "$GEM_HOST" >&2
43
+ exit 1
44
+ ;;
45
+ esac
46
+
47
+ # We only want this part running in CI, with no ~/.gem dir
48
+ # For local testing, you should have a ~/.gem/credentials file with
49
+ # the keys you need to push to rubygems or github
50
+ if [ ! -d ~/.gem ]
51
+ then
52
+ if [ -z "$GEM_TOKEN" ]
53
+ then
54
+ printf 'No GEM_TOKEN provided, cannot publish\n' >&2
55
+ exit 1
56
+ fi
57
+ mkdir -p ~/.gem
58
+ printf '%s\n:%s: %s\n' '---' "$gem_key" "$GEM_TOKEN" > ~/.gem/credentials
59
+ chmod 600 ~/.gem/credentials
60
+ fi
61
+
62
+ bundle exec gem build
63
+ if [ -f "$here"/../.version.txt ]
64
+ then
65
+ version=$(<"$here"/../.version.txt)
66
+ else
67
+ version=$(git describe --tags --abbrev=0 | sed -e 's/^v//')
68
+ fi
69
+
70
+ if [ -z "$version" ]
71
+ then
72
+ gem="$(ls "$here"/../"$GEM_NAME"-*.gem | tail -1)"
73
+ else
74
+ gem="$(printf '%s/../%s-%s.gem' "$here" "$GEM_NAME" "$version")"
75
+ fi
76
+
77
+ if [ ! -f "$gem" ]
78
+ then
79
+ printf 'No gem file found: %s\n' "$gem" >&2
80
+ exit 1
81
+ fi
82
+
83
+ if [[ "${TRACE:-false}" == true || "${ACTIONS_STEP_DEBUG:-false}" == true ]]
84
+ then
85
+ printf "DEBUG: [%s] Building And Publishing %s to %s\n" "$just_me" "$gem" "$gem_host" >&2
86
+ fi
87
+
88
+ bundle exec gem push -k "$gem_key" --host "$gem_host" "$(basename "$gem")"
89
+
90
+ # vim: set foldmethod=marker et ts=4 sts=4 sw=4 ft=bash :
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/leopard/nats_api_server'
5
+
6
+ # Example to echo the given message
7
+ class EchoService
8
+ include Rubyists::Leopard::NatsApiServer
9
+
10
+ endpoint(:echo) { |msg| Success(msg.data) }
11
+ end
12
+
13
+ if __FILE__ == $PROGRAM_NAME
14
+ EchoService.run(
15
+ nats_url: 'nats://localhost:4222',
16
+ service_opts: { name: 'example.echo', version: '1.0.0' },
17
+ instances: 4,
18
+ )
19
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyists
4
+ module Leopard
5
+ class Error < StandardError; end
6
+ class ConfigurationError < Error; end
7
+ class ResultError < Error; end
8
+ end
9
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Rubyists
6
+ module Leopard
7
+ class MessageWrapper
8
+ # @!attribute [r] raw
9
+ # @return [NATS::Message] The original NATS message.
10
+ #
11
+ # @!attribute [r] data
12
+ # @return [Object] The parsed data from the NATS message.
13
+ #
14
+ # @!attribute [r] headers
15
+ # @return [Hash] The headers from the NATS message.
16
+ attr_reader :raw, :data, :headers
17
+
18
+ # @param nats_msg [NATS::Message] The NATS message to wrap.
19
+ def initialize(nats_msg)
20
+ @raw = nats_msg
21
+ @data = parse_data(nats_msg.data)
22
+ @headers = nats_msg.header.to_h
23
+ end
24
+
25
+ # @param payload [Object] The payload to respond with.
26
+ #
27
+ # @return [void]
28
+ def respond(payload)
29
+ raw.respond(serialize(payload))
30
+ end
31
+
32
+ # @param err [String, Exception] The error message or exception to respond with.
33
+ # @param code [Integer] The HTTP status code to use for the error response.
34
+ #
35
+ # @return [void]
36
+ def respond_with_error(err, code: 500)
37
+ raw.respond_with_error(err.to_s, code:)
38
+ end
39
+
40
+ private
41
+
42
+ # Parses the raw data from the NATS message.
43
+ # Assumes the data is in JSON format.
44
+ # If parsing fails, it returns the raw string.
45
+ #
46
+ # @param raw [String] The raw data from the NATS message.
47
+ #
48
+ # @return [Object] The parsed data, or the raw string if parsing fails.
49
+ def parse_data(raw)
50
+ JSON.parse(raw)
51
+ rescue JSON::ParserError
52
+ raw
53
+ end
54
+
55
+ # Serializes the object to a JSON string if it is not already a string.
56
+ # @param obj [Object] The object to serialize.
57
+ #
58
+ # @return [String] The serialized JSON string or the original string.
59
+ def serialize(obj)
60
+ obj.is_a?(String) ? obj : JSON.generate(obj)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nats/client'
4
+ require 'dry/monads'
5
+ require 'concurrent'
6
+ require_relative '../leopard'
7
+ require_relative 'message_wrapper'
8
+
9
+ module Rubyists
10
+ module Leopard
11
+ module NatsApiServer
12
+ include Dry::Monads[:result]
13
+ extend Dry::Monads[:result]
14
+
15
+ def self.included(base)
16
+ base.extend(ClassMethods)
17
+ base.extend(Dry::Monads[:result])
18
+ base.include(SemanticLogger::Loggable)
19
+ end
20
+
21
+ module ClassMethods
22
+ def endpoints = @endpoints ||= []
23
+ def groups = @groups ||= {}
24
+ def middleware = @middleware ||= []
25
+
26
+ # Define an endpoint for the NATS API server.
27
+ #
28
+ # @param name [String] The name of the endpoint.
29
+ # @param subject [String, nil] The NATS subject to listen on. Defaults to the endpoint name.
30
+ # @param queue [String, nil] The NATS queue group to use. Defaults to nil.
31
+ # @param group [String, nil] The group this endpoint belongs to. Defaults to nil.
32
+ # @param handler [Proc] The block that will handle incoming messages.
33
+ #
34
+ # @return [void]
35
+ def endpoint(name, subject: nil, queue: nil, group: nil, &handler)
36
+ endpoints << {
37
+ name:,
38
+ subject: subject || name,
39
+ queue:,
40
+ group:,
41
+ handler:,
42
+ }
43
+ end
44
+
45
+ # Define a group for organizing endpoints.
46
+ #
47
+ # @param name [String] The name of the group.
48
+ # @param group [String, nil] The parent group this group belongs to. Defaults to nil.
49
+ # @param queue [String, nil] The NATS queue group to use for this group. Defaults to nil.
50
+ #
51
+ # @return [void]
52
+ def group(name, group: nil, queue: nil)
53
+ groups[name] = { name:, parent: group, queue: }
54
+ end
55
+
56
+ # Use a middleware class for processing messages.
57
+ #
58
+ # @param klass [Class] The middleware class to use.
59
+ # @param args [Array] Optional arguments to pass to the middleware class.
60
+ # @param block [Proc] Optional block to pass to the middleware class.
61
+ #
62
+ # @return [void]
63
+ def use(klass, *args, &block)
64
+ middleware << [klass, args, block]
65
+ end
66
+
67
+ # Start the NATS API server.
68
+ # This method connects to the NATS server and spawns multiple instances of the API server.
69
+ #
70
+ # @param nats_url [String] The URL of the NATS server to connect to.
71
+ # @param service_opts [Hash] Options for the NATS service.
72
+ # @param instances [Integer] The number of instances to spawn. Defaults to 1.
73
+ # @param blocking [Boolean] If false, does not block current thread after starting the server. Defaults to true.
74
+ #
75
+ # @return [void]
76
+ def run(nats_url:, service_opts:, instances: 1, blocking: true)
77
+ logger.info 'Booting NATS API server...'
78
+ # Return the thread pool if non-blocking
79
+ return spawn_instances(nats_url, service_opts, instances) unless blocking
80
+
81
+ # Otherwise, just sleep the main thread forever
82
+ sleep
83
+ end
84
+
85
+ private
86
+
87
+ # Spawns multiple instances of the NATS API server.
88
+ #
89
+ # @param url [String] The URL of the NATS server.
90
+ # @param opts [Hash] Options for the NATS service.
91
+ # @param count [Integer] The number of instances to spawn.
92
+ #
93
+ # @return [Concurrent::FixedThreadPool] The thread pool managing the worker threads.
94
+ def spawn_instances(url, opts, count)
95
+ pool = Concurrent::FixedThreadPool.new(count)
96
+ count.times do
97
+ eps = endpoints.dup
98
+ gps = groups.dup
99
+ pool.post { setup_worker(url, opts, eps, gps) }
100
+ end
101
+ pool
102
+ end
103
+
104
+ # Sets up a worker thread for the NATS API server.
105
+ # This method connects to the NATS server, adds the service, groups, and endpoints,
106
+ # and keeps the worker thread alive.
107
+ #
108
+ # @param url [String] The URL of the NATS server.
109
+ # @param opts [Hash] Options for the NATS service.
110
+ # @param eps [Array<Hash>] The list of endpoints to add.
111
+ # @param gps [Hash] The groups to add.
112
+ #
113
+ # @return [void]
114
+ def setup_worker(url, opts, eps, gps)
115
+ client = NATS.connect url
116
+ service = client.services.add(**opts)
117
+ group_map = add_groups(service, gps)
118
+ add_endpoints service, eps, group_map
119
+ # Keep the worker thread alive
120
+ sleep
121
+ end
122
+
123
+ # Adds groups to the NATS service.
124
+ #
125
+ # @param service [NATS::Service] The NATS service to add groups to.
126
+ # @param gps [Hash] The groups to add, where keys are group names and values are group definitions.
127
+ #
128
+ # @return [Hash] A map of group names to their created group objects.
129
+ def add_groups(service, gps)
130
+ created = {}
131
+ gps.each_key { |name| build_group(service, gps, created, name) }
132
+ created
133
+ end
134
+
135
+ # Builds a group in the NATS service.
136
+ #
137
+ # @param service [NATS::Service] The NATS service to add the group to.
138
+ # @param defs [Hash] The group definitions, where keys are group names and values are group definitions.
139
+ # @param cache [Hash] A cache to store already created groups.
140
+ # @param name [String] The name of the group to build.
141
+ #
142
+ # @return [NATS::Group] The created group object.
143
+ def build_group(service, defs, cache, name)
144
+ return cache[name] if cache.key?(name)
145
+
146
+ gdef = defs[name]
147
+ raise ArgumentError, "Group #{name} not defined" unless gdef
148
+
149
+ parent = gdef[:parent] ? build_group(service, defs, cache, gdef[:parent]) : service
150
+ cache[name] = parent.groups.add(gdef[:name], queue: gdef[:queue])
151
+ end
152
+
153
+ # Adds endpoints to the NATS service.
154
+ #
155
+ # @param service [NATS::Service] The NATS service to add endpoints to.
156
+ # @param endpoints [Array<Hash>] The list of endpoints to add.
157
+ # @param group_map [Hash] A map of group names to their created group objects.
158
+ #
159
+ # @return [void]
160
+ def add_endpoints(service, endpoints, group_map)
161
+ endpoints.each do |ep|
162
+ parent = ep[:group] ? group_map[ep[:group]] : service
163
+ raise ArgumentError, "Group #{ep[:group]} not defined" if ep[:group] && parent.nil?
164
+
165
+ parent.endpoints.add(
166
+ ep[:name], subject: ep[:subject], queue: ep[:queue]
167
+ ) do |raw_msg|
168
+ wrapper = MessageWrapper.new(raw_msg)
169
+ dispatch_with_middleware(wrapper, ep[:handler])
170
+ end
171
+ end
172
+ end
173
+
174
+ # Dispatches a message through the middleware stack and handles it with the provided handler.
175
+ #
176
+ # @param wrapper [MessageWrapper] The message wrapper containing the raw message.
177
+ # @param handler [Proc] The handler to process the message.
178
+ #
179
+ # @return [void]
180
+ def dispatch_with_middleware(wrapper, handler)
181
+ app = ->(w) { handle_message(w.raw, handler) }
182
+ middleware.reverse_each do |(klass, args, blk)|
183
+ app = klass.new(app, *args, &blk)
184
+ end
185
+ app.call(wrapper)
186
+ end
187
+
188
+ # Handles a raw NATS message using the provided handler.
189
+ #
190
+ # @param raw_msg [NATS::Message] The raw NATS message to handle.
191
+ # @param handler [Proc] The handler to process the message.
192
+ #
193
+ # @return [void]
194
+ def handle_message(raw_msg, handler)
195
+ wrapper = MessageWrapper.new(raw_msg)
196
+ result = instance_exec(wrapper, &handler)
197
+ process_result(wrapper, result)
198
+ rescue StandardError => e
199
+ logger.error 'Error processing message: ', e
200
+ wrapper.respond_with_error(e.message)
201
+ end
202
+
203
+ # Processes the result of the handler execution.
204
+ #
205
+ #
206
+ # @param wrapper [MessageWrapper] The message wrapper containing the raw message.
207
+ # @param result [Dry::Monads::Result] The result of the handler execution.
208
+ #
209
+ # @return [void]
210
+ # @raise [ResultError] If the result is not a Success or Failure monad.
211
+ def process_result(wrapper, result)
212
+ case result
213
+ in Dry::Monads::Success
214
+ wrapper.respond(result.value!)
215
+ in Dry::Monads::Failure
216
+ logger.error 'Error processing message: ', result.failure
217
+ wrapper.respond_with_error(result.failure)
218
+ else
219
+ logger.error('Unexpected result: ', result:)
220
+ raise ResultError, "Unexpected Response from Handler, must respond with a Success or Failure monad: #{result}"
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'semantic_logger'
4
+
5
+ module Rubyists
6
+ module Leopard
7
+ extend Dry::Configurable
8
+
9
+ setting :libroot, reader: true, default: Pathname(__FILE__).dirname.join('..').expand_path
10
+ setting :root, reader: true, default: libroot.join('..').expand_path
11
+ setting :logger, reader: true, default: SemanticLogger[:Leopard]
12
+ end
13
+ end
@@ -3,7 +3,7 @@
3
3
  module Rubyists
4
4
  module Leopard
5
5
  # x-release-please-start-version
6
- VERSION = '0.1.0'
6
+ VERSION = '0.1.3'
7
7
  # x-release-please-end
8
8
  end
9
9
  end
data/lib/leopard.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/configurable'
4
+ require 'pathname'
5
+ require 'semantic_logger'
6
+ SemanticLogger.add_appender(io: $stdout, formatter: :color)
7
+
8
+ class Pathname
9
+ def /(other)
10
+ join other.to_s
11
+ end
12
+ end
13
+
14
+ module Rubyists
15
+ module Leopard
16
+ end
17
+ end
18
+
19
+ require_relative 'leopard/settings'
20
+ require_relative 'leopard/version'
21
+ require_relative 'leopard/errors'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leopard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - bougyman
@@ -9,6 +9,48 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: concurrent-ruby
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: dry-configurable
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.3'
40
+ - !ruby/object:Gem::Dependency
41
+ name: dry-monads
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.9'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.9'
12
54
  - !ruby/object:Gem::Dependency
13
55
  name: nats-pure
14
56
  requirement: !ruby/object:Gem::Requirement
@@ -23,6 +65,20 @@ dependencies:
23
65
  - - "~>"
24
66
  - !ruby/object:Gem::Version
25
67
  version: '2.5'
68
+ - !ruby/object:Gem::Dependency
69
+ name: semantic_logger
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '4'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '4'
26
82
  description: Leopard is a puma-like server for managing concurrent NATS ServiceApi
27
83
  endpoint workers
28
84
  email:
@@ -31,7 +87,23 @@ executables: []
31
87
  extensions: []
32
88
  extra_rdoc_files: []
33
89
  files:
90
+ - ".release-please-config.json"
91
+ - ".release-please-manifest.json"
92
+ - ".rubocop.yml"
93
+ - ".version.txt"
94
+ - CHANGELOG.md
95
+ - Rakefile
34
96
  - Readme.adoc
97
+ - ci/build_image.sh
98
+ - ci/nats/accounts.txt
99
+ - ci/nats/start.sh
100
+ - ci/publish-gem.sh
101
+ - examples/echo_endpoint.rb
102
+ - lib/leopard.rb
103
+ - lib/leopard/errors.rb
104
+ - lib/leopard/message_wrapper.rb
105
+ - lib/leopard/nats_api_server.rb
106
+ - lib/leopard/settings.rb
35
107
  - lib/leopard/version.rb
36
108
  homepage: https://github.com/rubyists/leopard
37
109
  licenses: