magicprotorb 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/.rubocop.yml +39 -0
- data/Cargo.lock +517 -0
- data/Cargo.toml +6 -0
- data/DESIGN.md +163 -0
- data/README.md +124 -0
- data/Rakefile +36 -0
- data/ext/magicprotorb_native/Cargo.toml +22 -0
- data/ext/magicprotorb_native/extconf.rb +8 -0
- data/ext/magicprotorb_native/src/lib.rs +35 -0
- data/lib/magicprotorb/include_path.rb +43 -0
- data/lib/magicprotorb/loader.rb +49 -0
- data/lib/magicprotorb/naming.rb +33 -0
- data/lib/magicprotorb/registrar.rb +96 -0
- data/lib/magicprotorb/require_hook.rb +45 -0
- data/lib/magicprotorb/service_builder.rb +62 -0
- data/lib/magicprotorb/version.rb +5 -0
- data/lib/magicprotorb.rb +55 -0
- data/sig/magicprotorb.rbs +29 -0
- metadata +106 -0
data/DESIGN.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Design
|
|
2
|
+
|
|
3
|
+
`magicprotorb` is the Ruby counterpart of [magicproto](https://example.com/magicproto)
|
|
4
|
+
(Python). The goal is identical: make a `.proto` file importable directly, with
|
|
5
|
+
no `protoc` invocation, no checked-in generated code, and no build step in your
|
|
6
|
+
edit/run loop. Only the host-language mechanics differ.
|
|
7
|
+
|
|
8
|
+
## The core idea
|
|
9
|
+
|
|
10
|
+
A generated protobuf file is a pure function of two things: the `.proto` source
|
|
11
|
+
and the path it lives at. Checking the output into your repo creates a third,
|
|
12
|
+
independent thing that has to be kept in sync by hand — and the failure mode
|
|
13
|
+
everyone has hit is the generated `require`/`import` pointing somewhere the
|
|
14
|
+
descriptor name doesn't actually live.
|
|
15
|
+
|
|
16
|
+
`magicprotorb` removes the third thing. The require path *is* the proto path:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
require "magicprotorb/greet/hello_pb" <-> greet/hello.proto
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
There is exactly one source of truth (the `.proto`), and the mapping between the
|
|
23
|
+
require name, the file on disk, and the descriptor's fully-qualified name is the
|
|
24
|
+
identity function. They cannot drift because there is nothing to keep in sync.
|
|
25
|
+
|
|
26
|
+
## The pipeline
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
require "magicprotorb/greet/hello_pb"
|
|
30
|
+
│
|
|
31
|
+
▼
|
|
32
|
+
RequireHook claim "magicprotorb/<x>_pb" / "<x>_services_pb"; strip suffix
|
|
33
|
+
│ -> canonical proto path "greet/hello.proto"
|
|
34
|
+
▼
|
|
35
|
+
IncludePath resolve against include roots (MAGICPROTORB_PATH, then $LOAD_PATH)
|
|
36
|
+
│
|
|
37
|
+
▼
|
|
38
|
+
Compiler._compile Rust ext (protox) -> serialized FileDescriptorSet [the missing piece]
|
|
39
|
+
│
|
|
40
|
+
▼
|
|
41
|
+
Registrar decode FDS; add_serialized_file each file (dep order, idempotent);
|
|
42
|
+
│ assign Ruby constants exactly as a generated _pb.rb would
|
|
43
|
+
▼
|
|
44
|
+
ServiceBuilder (only for _services_pb) synthesize GRPC::GenericService + Stub
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Why a native extension at all
|
|
48
|
+
|
|
49
|
+
The stock `google-protobuf` runtime can *consume* a serialized
|
|
50
|
+
`FileDescriptorSet` (`DescriptorPool#add_serialized_file`) and it ships the
|
|
51
|
+
descriptor.proto message types (`Google::Protobuf::FileDescriptorSet` et al.) —
|
|
52
|
+
but it cannot *produce* descriptors from `.proto` text. Compiling `.proto` is
|
|
53
|
+
precisely the job of `protoc`.
|
|
54
|
+
|
|
55
|
+
`magicprotorb` does that one step with [`protox`](https://crates.io/crates/protox),
|
|
56
|
+
a pure-Rust protobuf compiler, wrapped in a tiny [`magnus`](https://crates.io/crates/magnus)
|
|
57
|
+
extension (`ext/magicprotorb_native`). The extension exposes a single method:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
Magicprotorb::Compiler._compile(proto_path, include_dirs) # => String (serialized FileDescriptorSet)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Everything above that line is ordinary Ruby talking to the ordinary protobuf
|
|
64
|
+
runtime. This mirrors magicproto, which wraps the same `protox` crate.
|
|
65
|
+
|
|
66
|
+
### Why constants are assigned by hand
|
|
67
|
+
|
|
68
|
+
`add_serialized_file` registers descriptors in the pool but does **not** create
|
|
69
|
+
any Ruby constants — a generated `_pb.rb` is what does
|
|
70
|
+
`HelloRequest = pool.lookup("greet.HelloRequest").msgclass`. So the `Registrar`
|
|
71
|
+
walks the `FileDescriptorProto` and performs the same assignments, byte-for-byte
|
|
72
|
+
compatible with `protoc`'s Ruby generator:
|
|
73
|
+
|
|
74
|
+
- `package a_b.c` → nested modules `A_b`-style camelization → `AB::C`
|
|
75
|
+
(each `_`-delimited part of each segment is capitalized: `my_co` → `MyCo`).
|
|
76
|
+
- messages → `lookup(full_name).msgclass`, nested messages under their parent
|
|
77
|
+
constant (`Book::Chapter`).
|
|
78
|
+
- enums → `lookup(full_name).enummodule`.
|
|
79
|
+
- synthetic `map<>` entry messages get no constant (protoc skips them too).
|
|
80
|
+
|
|
81
|
+
The result is genuinely the same class you'd get from generated code — same
|
|
82
|
+
descriptor object, same `#encode`/`#decode`, same `.name`.
|
|
83
|
+
|
|
84
|
+
## Multi-package / namespacing model
|
|
85
|
+
|
|
86
|
+
Two independent axes, deliberately kept separate:
|
|
87
|
+
|
|
88
|
+
1. **File location** is what you import and what `protoc -I` resolves. It is
|
|
89
|
+
*not* required to match the proto `package`. Put `foo/bar.proto` where you'd
|
|
90
|
+
put `foo/bar.rb` and import `magicprotorb/foo/bar_pb`.
|
|
91
|
+
|
|
92
|
+
2. **Proto `package`** is what determines the Ruby module nesting of the
|
|
93
|
+
resulting constants, via the protoc naming rule above.
|
|
94
|
+
|
|
95
|
+
Because the include roots are `MAGICPROTORB_PATH` then `$LOAD_PATH`, a gem ships
|
|
96
|
+
its protos under its own `lib/` and the **directory name namespaces them**: gem
|
|
97
|
+
`acme` ships `lib/acme/widgets.proto`, you `require "magicprotorb/acme/widgets_pb"`,
|
|
98
|
+
and a second gem physically cannot shadow it without colliding on the same
|
|
99
|
+
directory. This is the `protoc -I` include model, reused verbatim.
|
|
100
|
+
|
|
101
|
+
### Imports
|
|
102
|
+
|
|
103
|
+
Compiling `greet/hello.proto` compiles its transitive imports too; every file in
|
|
104
|
+
the returned `FileDescriptorSet` is registered into the pool (in dependency
|
|
105
|
+
order, idempotently). Constants are also assigned for imported, non-well-known
|
|
106
|
+
files, matching `protoc`'s behavior of emitting `require`s for each dependency.
|
|
107
|
+
Well-known types (`google/protobuf/*`) are owned by the runtime and are neither
|
|
108
|
+
re-registered nor re-named.
|
|
109
|
+
|
|
110
|
+
## gRPC
|
|
111
|
+
|
|
112
|
+
`require "magicprotorb/greet/hello_services_pb"` first does the message load
|
|
113
|
+
(register + constants), then reads the service descriptors of *that file only*
|
|
114
|
+
(matching `protoc`'s per-file output) and builds, for each service:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
module Greet
|
|
118
|
+
module Greeter
|
|
119
|
+
class Service
|
|
120
|
+
include GRPC::GenericService
|
|
121
|
+
self.marshal_class_method = :encode
|
|
122
|
+
self.unmarshal_class_method = :decode
|
|
123
|
+
self.service_name = "greet.Greeter"
|
|
124
|
+
rpc :SayHello, Greet::HelloRequest, Greet::HelloReply
|
|
125
|
+
rpc :SayHelloStream, Greet::HelloRequest, stream(Greet::HelloReply)
|
|
126
|
+
end
|
|
127
|
+
Stub = Service.rpc_stub_class
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`require "grpc"` is lazy, so message-only users never load it. Streaming sides
|
|
133
|
+
are wrapped with the DSL's `stream(...)`, exactly as generated code does.
|
|
134
|
+
|
|
135
|
+
## Idempotency & concurrency
|
|
136
|
+
|
|
137
|
+
- Compilation results are memoized per canonical proto path.
|
|
138
|
+
- `add_serialized_file` rejects duplicate file names; the `Registrar` tracks
|
|
139
|
+
loaded files and also rescues that specific error, so re-requiring, shared
|
|
140
|
+
imports, and pre-registered well-known types are all no-ops.
|
|
141
|
+
- A re-`require` of an already-loaded module returns `false`, like `Kernel#require`.
|
|
142
|
+
- A process-wide mutex serializes the pool mutations.
|
|
143
|
+
|
|
144
|
+
## Limitations
|
|
145
|
+
|
|
146
|
+
- **A compiler is needed at *install* time.** The Rust extension is compiled
|
|
147
|
+
when the gem installs (a Rust toolchain / `cargo` must be present). The point
|
|
148
|
+
of magicprotorb is removing `protoc` from your *edit/run* loop, not removing
|
|
149
|
+
all native build steps; the trade is "compile a small Rust crate once at
|
|
150
|
+
install" for "never run protoc again."
|
|
151
|
+
- **Constants appear at require time, not parse time.** Editors/static analyzers
|
|
152
|
+
that don't execute the require won't see `Greet::HelloRequest`. This is
|
|
153
|
+
inherent to runtime code generation (same caveat as magicproto).
|
|
154
|
+
- **The hook only claims `magicprotorb/…_pb` / `…_services_pb`.** Anything else
|
|
155
|
+
(including the native extension and the version file) falls straight through to
|
|
156
|
+
the real `require`.
|
|
157
|
+
- **One descriptor pool.** Everything registers into
|
|
158
|
+
`DescriptorPool.generated_pool`, exactly like generated code; two different
|
|
159
|
+
protos defining the same fully-qualified type still conflict, just as they
|
|
160
|
+
would with `protoc`.
|
|
161
|
+
- **`required_ruby_version >= 3.0`**; descriptor types are loaded via
|
|
162
|
+
`google/protobuf/descriptor_pb`, which is required explicitly for compatibility
|
|
163
|
+
with protobuf 4.x where it is not auto-loaded.
|
data/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# magicprotorb
|
|
2
|
+
|
|
3
|
+
Import `.proto` files directly in Ruby. No `protoc`, no generated `_pb.rb` files, no build step:
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
require "magicprotorb" # installs the import hook
|
|
7
|
+
require "magicprotorb/greet/hello_pb" # compiles greet/hello.proto
|
|
8
|
+
require "magicprotorb/greet/hello_services_pb" # + synthesizes the gRPC stub
|
|
9
|
+
|
|
10
|
+
req = Greet::HelloRequest.new(name: "world")
|
|
11
|
+
stub = Greet::Greeter::Stub.new("localhost:50051", :this_channel_is_insecure)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`require "magicprotorb/greet/hello_pb"` compiles `greet/hello.proto` (found on
|
|
15
|
+
`MAGICPROTORB_PATH` / `$LOAD_PATH`) at require time and defines the message
|
|
16
|
+
constants. The dotted require path mirrors the canonical proto path 1:1, so the
|
|
17
|
+
require name, the file location, and the descriptor name can never drift apart —
|
|
18
|
+
the classic "the generated import points at the wrong place" problem cannot occur.
|
|
19
|
+
|
|
20
|
+
## How it works
|
|
21
|
+
|
|
22
|
+
A `Kernel#require` hook claims names under `magicprotorb/` that end in `_pb` or
|
|
23
|
+
`_services_pb` (only).
|
|
24
|
+
|
|
25
|
+
- `magicprotorb/greet/hello_pb` → canonical `greet/hello.proto`, located on the
|
|
26
|
+
include roots (the `protoc -I` model: `MAGICPROTORB_PATH` then `$LOAD_PATH`).
|
|
27
|
+
- A small Rust extension (`magicprotorb_native`, built on the pure-Rust
|
|
28
|
+
[`protox`](https://crates.io/crates/protox) compiler) turns the `.proto` into a
|
|
29
|
+
serialized `FileDescriptorSet` — the one thing the stock protobuf runtime can't
|
|
30
|
+
do itself.
|
|
31
|
+
- Those descriptors are registered through the stock
|
|
32
|
+
`Google::Protobuf::DescriptorPool.generated_pool#add_serialized_file`, and the
|
|
33
|
+
message/enum constants are assigned exactly the way a generated `_pb.rb` does,
|
|
34
|
+
so the message classes are indistinguishable from generated ones.
|
|
35
|
+
- `_services_pb` modules are synthesized directly from the service descriptors as
|
|
36
|
+
ordinary `GRPC::GenericService` classes (`require "grpc"` happens lazily).
|
|
37
|
+
|
|
38
|
+
See [DESIGN.md](DESIGN.md) for the full rationale, the multi-package namespacing
|
|
39
|
+
model, and the limitations.
|
|
40
|
+
|
|
41
|
+
## Where to put protos
|
|
42
|
+
|
|
43
|
+
Put `foo/bar.proto` where you'd want `foo/bar.rb`, and import it as
|
|
44
|
+
`magicprotorb/foo/bar_pb`.
|
|
45
|
+
|
|
46
|
+
A library ships its protos as data inside its own `lib/` directory (already on
|
|
47
|
+
`$LOAD_PATH`); the directory name namespaces them, so two installed gems can't
|
|
48
|
+
collide.
|
|
49
|
+
|
|
50
|
+
### Finding your protos (include roots)
|
|
51
|
+
|
|
52
|
+
magicprotorb resolves a proto by its canonical path against the include roots —
|
|
53
|
+
`MAGICPROTORB_PATH` first, then `$LOAD_PATH` — exactly like `protoc -I`. Note that
|
|
54
|
+
Ruby does **not** put the current directory on `$LOAD_PATH`, so a proto sitting
|
|
55
|
+
next to your script isn't found automatically. Make its directory an include root:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
require "magicprotorb"
|
|
59
|
+
$LOAD_PATH.unshift __dir__ # this script's dir is now an include root
|
|
60
|
+
require "magicprotorb/keyvalue_pb" # resolves ./keyvalue.proto
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
or point `MAGICPROTORB_PATH` at it from the shell:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
MAGICPROTORB_PATH="$PWD" ruby my_script.rb
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
## Naming
|
|
71
|
+
|
|
72
|
+
| require | compiles | gives you |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `magicprotorb/greet/hello_pb` | `greet/hello.proto` | `Greet::HelloRequest`, ... |
|
|
75
|
+
| `magicprotorb/greet/hello_services_pb` | `greet/hello.proto` | `Greet::Greeter::Service` / `::Stub` |
|
|
76
|
+
|
|
77
|
+
The proto `package` becomes the Ruby module path the same way `protoc`'s Ruby
|
|
78
|
+
generator does it: `package my_co.sub_pkg.v1;` → `MyCo::SubPkg::V1`.
|
|
79
|
+
|
|
80
|
+
There is also a programmatic API equivalent to the requires:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
Magicprotorb.import("greet/hello") # like require "magicprotorb/greet/hello_pb"
|
|
84
|
+
Magicprotorb.import_services("greet/hello") # like require "magicprotorb/greet/hello_services_pb"
|
|
85
|
+
Magicprotorb.include_paths # the roots currently searched
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Installation
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
# Gemfile
|
|
92
|
+
gem "magicprotorb"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Building the gem compiles the bundled Rust extension, so a Rust toolchain
|
|
96
|
+
(`cargo`) is required at install time. The runtime needs only `google-protobuf`
|
|
97
|
+
(and `grpc`, if you import services).
|
|
98
|
+
|
|
99
|
+
### Installing from a local checkout
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
bundle exec rake install # builds the gem and installs it (native ext included)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
After this, `require "magicprotorb"` works from any script without `-I`. The gem
|
|
106
|
+
installs into whichever Ruby is active (`rbenv`/`rvm`), so install under the same
|
|
107
|
+
Ruby you'll run with.
|
|
108
|
+
|
|
109
|
+
> The `install` task deliberately runs `gem install` **outside** the bundle
|
|
110
|
+
> (`Bundler.with_unbundled_env`). A native-extension gem whose own gemspec is the
|
|
111
|
+
> bundle's path gem otherwise fails to build at install time, because RubyGems'
|
|
112
|
+
> per-extension build dir goes missing under `bundle exec`.
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
After checking out the repo:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
bin/setup # install dependencies
|
|
120
|
+
bundle exec rake # compile the extension, then run the tests
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`bundle exec rake compile` builds `ext/magicprotorb_native` into
|
|
124
|
+
`lib/magicprotorb/`. The fixture protos live in `test/protos`.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
require "rb_sys/extensiontask"
|
|
6
|
+
|
|
7
|
+
GEMSPEC = Gem::Specification.load("magicprotorb.gemspec")
|
|
8
|
+
|
|
9
|
+
# Builds ext/magicprotorb_native and drops the artifact in lib/magicprotorb/.
|
|
10
|
+
RbSys::ExtensionTask.new("magicprotorb_native", GEMSPEC) do |ext|
|
|
11
|
+
ext.lib_dir = "lib/magicprotorb"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
Rake::TestTask.new(test: :compile) do |t|
|
|
15
|
+
t.libs << "test" << "lib"
|
|
16
|
+
t.test_files = FileList["test/**/test_*.rb"]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
require "rubocop/rake_task"
|
|
20
|
+
RuboCop::RakeTask.new
|
|
21
|
+
|
|
22
|
+
# bundler/gem_tasks defines `install`, but it shells out to `gem install` while
|
|
23
|
+
# still inside the bundle. Because this gem's own gemspec is the bundle's path
|
|
24
|
+
# gem, that breaks the native-extension build at install time (RubyGems' build
|
|
25
|
+
# staging dir, ext/.../.gem.<ts>, ends up missing). Re-run the install outside
|
|
26
|
+
# the bundle, where `gem install` of our compiled .gem works correctly.
|
|
27
|
+
Rake::Task["install"].clear
|
|
28
|
+
desc "Build magicprotorb and install it (native extension compiled outside the bundle)"
|
|
29
|
+
task install: :build do
|
|
30
|
+
gem_file = "pkg/#{GEMSPEC.full_name}.gem"
|
|
31
|
+
Bundler.with_unbundled_env do
|
|
32
|
+
sh "gem", "install", gem_file
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
task default: %i[compile test]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "magicprotorb_native"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
publish = false
|
|
6
|
+
autobins = false
|
|
7
|
+
|
|
8
|
+
# The compiled artifact is loaded by Ruby as `magicprotorb/magicprotorb_native`.
|
|
9
|
+
# It deliberately does NOT end in `_pb`, so the require hook never claims it.
|
|
10
|
+
[lib]
|
|
11
|
+
name = "magicprotorb_native"
|
|
12
|
+
crate-type = ["cdylib"]
|
|
13
|
+
|
|
14
|
+
[dependencies]
|
|
15
|
+
# Ruby <-> Rust bindings.
|
|
16
|
+
magnus = "0.7"
|
|
17
|
+
# Pure-Rust protobuf *compiler* — the one thing the stock protobuf runtime
|
|
18
|
+
# cannot do itself (turns .proto text into a FileDescriptorSet).
|
|
19
|
+
protox = "0.9"
|
|
20
|
+
# protox builds on prost's descriptor types; keep these in lockstep with it.
|
|
21
|
+
prost = "0.14"
|
|
22
|
+
prost-types = "0.14"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//! The native half of magicprotorb.
|
|
2
|
+
//!
|
|
3
|
+
//! Exactly one job: take a canonical proto path plus a list of include roots
|
|
4
|
+
//! (the `protoc -I` model) and return a *serialized `FileDescriptorSet`* — the
|
|
5
|
+
//! same bytes a `protoc --descriptor_set_out` invocation would emit, and the
|
|
6
|
+
//! same bytes the stock Ruby protobuf runtime knows how to register via
|
|
7
|
+
//! `DescriptorPool#add_serialized_file`.
|
|
8
|
+
//!
|
|
9
|
+
//! The compiler is `protox`, a pure-Rust protobuf compiler, so there is no
|
|
10
|
+
//! dependency on a `protoc` binary at run time.
|
|
11
|
+
|
|
12
|
+
use magnus::{function, prelude::*, Error, RString, Ruby};
|
|
13
|
+
use prost::Message;
|
|
14
|
+
|
|
15
|
+
/// Compile `file` (resolved against `includes`) and return the serialized
|
|
16
|
+
/// `FileDescriptorSet`, transitive dependencies included, as a binary string.
|
|
17
|
+
fn compile(ruby: &Ruby, file: String, includes: Vec<String>) -> Result<RString, Error> {
|
|
18
|
+
let fds = protox::compile([file], includes).map_err(|e| {
|
|
19
|
+
Error::new(
|
|
20
|
+
ruby.exception_runtime_error(),
|
|
21
|
+
format!("magicprotorb: failed to compile proto: {e}"),
|
|
22
|
+
)
|
|
23
|
+
})?;
|
|
24
|
+
Ok(RString::from_slice(&fds.encode_to_vec()))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[magnus::init]
|
|
28
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
29
|
+
let module = ruby.define_module("Magicprotorb")?;
|
|
30
|
+
let compiler = module.define_class("Compiler", ruby.class_object())?;
|
|
31
|
+
// Underscore-prefixed: the Ruby Compiler wrapper adds path resolution and
|
|
32
|
+
// error context on top of this raw call.
|
|
33
|
+
compiler.define_singleton_method("_compile", function!(compile, 2))?;
|
|
34
|
+
Ok(())
|
|
35
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magicprotorb
|
|
4
|
+
# Resolves canonical proto paths against include roots, mirroring the
|
|
5
|
+
# `protoc -I` model: MAGICPROTORB_PATH first, then Ruby's $LOAD_PATH.
|
|
6
|
+
#
|
|
7
|
+
# This is the Ruby analogue of magicproto's "MAGICPROTO_PATH then sys.path":
|
|
8
|
+
# a library ships its protos as package data under its own lib directory, that
|
|
9
|
+
# directory is already on $LOAD_PATH, and the directory name namespaces the
|
|
10
|
+
# protos so two installed gems can't collide.
|
|
11
|
+
module IncludePath
|
|
12
|
+
ENV_VAR = "MAGICPROTORB_PATH"
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Ordered, de-duplicated list of existing directories to search, highest
|
|
17
|
+
# priority first. These become the `-I` include roots handed to the compiler.
|
|
18
|
+
def roots
|
|
19
|
+
raw = []
|
|
20
|
+
if (env = ENV.fetch(ENV_VAR, nil)) && !env.empty?
|
|
21
|
+
raw.concat(env.split(File::PATH_SEPARATOR))
|
|
22
|
+
end
|
|
23
|
+
raw.concat($LOAD_PATH)
|
|
24
|
+
|
|
25
|
+
seen = {}
|
|
26
|
+
raw.each_with_object([]) do |dir, out|
|
|
27
|
+
next if dir.nil? || dir.to_s.empty?
|
|
28
|
+
|
|
29
|
+
path = File.expand_path(dir.to_s)
|
|
30
|
+
next if seen[path] || !File.directory?(path)
|
|
31
|
+
|
|
32
|
+
seen[path] = true
|
|
33
|
+
out << path
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The first root under which +proto_path+ exists, or nil. Used to produce a
|
|
38
|
+
# LoadError-shaped failure before invoking the compiler.
|
|
39
|
+
def resolve(proto_path)
|
|
40
|
+
roots.find { |root| File.file?(File.join(root, proto_path)) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magicprotorb
|
|
4
|
+
# Orchestrates a single import: resolve -> compile -> register (-> services).
|
|
5
|
+
# Compilation results are memoized per canonical proto path; a process-wide
|
|
6
|
+
# mutex serializes the descriptor-pool mutations.
|
|
7
|
+
module Loader
|
|
8
|
+
MUTEX = Mutex.new
|
|
9
|
+
@compiled = {}
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Register messages/enums for +proto_path+ (and its imports). Idempotent.
|
|
14
|
+
def load_messages(proto_path)
|
|
15
|
+
MUTEX.synchronize { Registrar.register(compile(proto_path)) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# As load_messages, plus synthesize gRPC Service/Stub for the proto's own
|
|
19
|
+
# services (not those of its imports — matching protoc's per-file output).
|
|
20
|
+
def load_services(proto_path)
|
|
21
|
+
MUTEX.synchronize do
|
|
22
|
+
fds = compile(proto_path)
|
|
23
|
+
Registrar.register(fds)
|
|
24
|
+
primary = fds.file.find { |file| file.name == proto_path } || fds.file.last
|
|
25
|
+
ServiceBuilder.build(primary)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Compile +proto_path+ to a FileDescriptorSet via the native protox-backed
|
|
30
|
+
# compiler, using the current include roots. Memoized.
|
|
31
|
+
def compile(proto_path)
|
|
32
|
+
@compiled[proto_path] ||= begin
|
|
33
|
+
if IncludePath.resolve(proto_path).nil?
|
|
34
|
+
raise LoadError,
|
|
35
|
+
"magicprotorb: cannot find #{proto_path} on #{IncludePath::ENV_VAR} or $LOAD_PATH"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
bytes =
|
|
39
|
+
begin
|
|
40
|
+
Compiler._compile(proto_path, IncludePath.roots)
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
raise CompileError, e.message
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Google::Protobuf::FileDescriptorSet.decode(bytes)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magicprotorb
|
|
4
|
+
# Translates proto identifiers into Ruby constant names exactly the way
|
|
5
|
+
# protoc's Ruby generator does, so the constants magicprotorb synthesizes are
|
|
6
|
+
# identical to those a checked-in `*_pb.rb` would define.
|
|
7
|
+
#
|
|
8
|
+
# package my_co.sub_pkg.v1 -> MyCo::SubPkg::V1
|
|
9
|
+
# message OuterMsg -> OuterMsg (nested under its parent)
|
|
10
|
+
#
|
|
11
|
+
# Each dot-separated package segment is split on "_" and each part is
|
|
12
|
+
# capitalized (my_co -> MyCo, v1 -> V1); message/enum names keep their own
|
|
13
|
+
# casing with only the first letter forced upper.
|
|
14
|
+
module Naming
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# ["MyCo", "SubPkg", "V1"] for "my_co.sub_pkg.v1"; [] for an empty package.
|
|
18
|
+
def package_modules(package)
|
|
19
|
+
return [] if package.nil? || package.empty?
|
|
20
|
+
|
|
21
|
+
package.split(".").map { |segment| camelize(segment) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def camelize(segment)
|
|
25
|
+
segment.split("_").map { |part| part.empty? ? "" : part[0].upcase + part[1..] }.join
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Constant name for a message/enum simple name (first letter upper).
|
|
29
|
+
def constant_name(simple_name)
|
|
30
|
+
simple_name[0].upcase + simple_name[1..]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "google/protobuf"
|
|
4
|
+
require "google/protobuf/descriptor_pb"
|
|
5
|
+
|
|
6
|
+
module Magicprotorb
|
|
7
|
+
# Takes a compiled FileDescriptorSet and registers it through the *stock*
|
|
8
|
+
# protobuf machinery — DescriptorPool.generated_pool#add_serialized_file plus
|
|
9
|
+
# the same constant assignments a generated `*_pb.rb` performs. The resulting
|
|
10
|
+
# message classes are therefore indistinguishable from generated ones.
|
|
11
|
+
module Registrar
|
|
12
|
+
# The protobuf runtime already owns google/protobuf/*; never re-register or
|
|
13
|
+
# assign constants for those (it would clobber Google::Protobuf::Timestamp
|
|
14
|
+
# and friends, and add_serialized_file would reject the duplicate).
|
|
15
|
+
WELL_KNOWN_PREFIX = "google/protobuf/"
|
|
16
|
+
|
|
17
|
+
@loaded_files = {}
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Register every file in the set (dependency-ordered by the compiler), then
|
|
22
|
+
# assign Ruby constants for each non-well-known file. Idempotent.
|
|
23
|
+
def register(file_descriptor_set)
|
|
24
|
+
pool = Google::Protobuf::DescriptorPool.generated_pool
|
|
25
|
+
file_descriptor_set.file.each { |file| register_file(pool, file) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def register_file(pool, file)
|
|
29
|
+
name = file.name
|
|
30
|
+
return if @loaded_files[name]
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
pool.add_serialized_file(file.to_proto)
|
|
34
|
+
rescue Google::Protobuf::TypeError => e
|
|
35
|
+
# Already present (a well-known type, or a shared import registered by a
|
|
36
|
+
# previously imported proto). Anything else is a real error.
|
|
37
|
+
raise unless e.message.include?("duplicate file name")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@loaded_files[name] = true
|
|
41
|
+
assign_constants(pool, file) unless name.start_with?(WELL_KNOWN_PREFIX)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# --- constant assignment (mirrors protoc's Ruby generator) ----------------
|
|
45
|
+
|
|
46
|
+
def assign_constants(pool, file)
|
|
47
|
+
package = file.package
|
|
48
|
+
scope = ensure_module(Naming.package_modules(package))
|
|
49
|
+
file.message_type.each { |msg| assign_message(pool, scope, package, msg) }
|
|
50
|
+
file.enum_type.each { |enum| assign_enum(pool, scope, package, enum.name) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def assign_message(pool, parent, scope_fullname, msg)
|
|
54
|
+
full_name = qualify(scope_fullname, msg.name)
|
|
55
|
+
klass = pool.lookup(full_name).msgclass
|
|
56
|
+
set_const(parent, Naming.constant_name(msg.name), klass)
|
|
57
|
+
|
|
58
|
+
# Synthetic map-entry messages get no constant (protoc skips them too).
|
|
59
|
+
msg.nested_type.each do |nested|
|
|
60
|
+
next if nested.options&.map_entry
|
|
61
|
+
|
|
62
|
+
assign_message(pool, klass, full_name, nested)
|
|
63
|
+
end
|
|
64
|
+
msg.enum_type.each { |enum| assign_enum(pool, klass, full_name, enum.name) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def assign_enum(pool, parent, scope_fullname, enum_name)
|
|
68
|
+
full_name = qualify(scope_fullname, enum_name)
|
|
69
|
+
set_const(parent, Naming.constant_name(enum_name), pool.lookup(full_name).enummodule)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# --- helpers --------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def qualify(scope, name)
|
|
75
|
+
scope.nil? || scope.empty? ? name : "#{scope}.#{name}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Walk/create a chain of modules (e.g. %w[MyCo SubPkg V1]) under Object,
|
|
79
|
+
# returning the innermost. An empty list returns Object (no-package case).
|
|
80
|
+
def ensure_module(segments)
|
|
81
|
+
segments.reduce(Object) do |parent, segment|
|
|
82
|
+
if parent.const_defined?(segment, false)
|
|
83
|
+
parent.const_get(segment, false)
|
|
84
|
+
else
|
|
85
|
+
mod = Module.new
|
|
86
|
+
parent.const_set(segment, mod)
|
|
87
|
+
mod
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def set_const(parent, name, value)
|
|
93
|
+
parent.const_set(name, value) unless parent.const_defined?(name, false)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magicprotorb
|
|
4
|
+
# Prepended onto Kernel so that a bare `require "magicprotorb/<proto>_pb"`
|
|
5
|
+
# compiles and registers <proto>.proto at require time. This is the Ruby
|
|
6
|
+
# counterpart to magicproto's sys.meta_path finder.
|
|
7
|
+
#
|
|
8
|
+
# Only names under "magicprotorb/" that end in "_pb" / "_services_pb" are
|
|
9
|
+
# claimed; everything else (including the native extension and version file)
|
|
10
|
+
# falls through to the real require via super.
|
|
11
|
+
module RequireHook
|
|
12
|
+
PREFIX = "magicprotorb/"
|
|
13
|
+
MUTEX = Mutex.new
|
|
14
|
+
@required = {}
|
|
15
|
+
|
|
16
|
+
def require(name)
|
|
17
|
+
handled = RequireHook.dispatch(name)
|
|
18
|
+
handled.nil? ? super : handled
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns true (loaded now), false (already loaded), or nil (not ours).
|
|
22
|
+
def self.dispatch(name)
|
|
23
|
+
return nil unless name.is_a?(String) && name.start_with?(PREFIX)
|
|
24
|
+
|
|
25
|
+
rest = name[PREFIX.length..]
|
|
26
|
+
if rest.end_with?("_services_pb")
|
|
27
|
+
proto = "#{rest.delete_suffix("_services_pb")}.proto"
|
|
28
|
+
loader = :load_services
|
|
29
|
+
elsif rest.end_with?("_pb")
|
|
30
|
+
proto = "#{rest.delete_suffix("_pb")}.proto"
|
|
31
|
+
loader = :load_messages
|
|
32
|
+
else
|
|
33
|
+
return nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
MUTEX.synchronize do
|
|
37
|
+
return false if @required[name]
|
|
38
|
+
|
|
39
|
+
Loader.public_send(loader, proto)
|
|
40
|
+
@required[name] = true
|
|
41
|
+
end
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|