ratalada-contrib 2.0.1 → 2.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 +4 -4
- data/lib/ratalada/contrib/router/file_based/grape_adapter.rb +54 -0
- data/lib/ratalada/contrib/router/file_based/hanami_adapter.rb +56 -0
- data/lib/ratalada/contrib/router/file_based/sinatra_adapter.rb +90 -0
- data/lib/ratalada/contrib/router/file_based.rb +119 -0
- data/lib/ratalada/contrib/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 684119c7ffaadc33fd820a4afad88d7c814e20d95cb397e3b7f200a48b89c252
|
|
4
|
+
data.tar.gz: de2d2c52778288e334bb95a5bb27420e062714d35924a8db5ab98d4b15491f93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c22ec36581b747dbeccacca95a0ae3d492286682365c5b79d234bfc9ad5dbaed4fb9fd9af5fe1f18933f7823109ce887bde7b6da6f3742636e11e9d91fdd743f
|
|
7
|
+
data.tar.gz: 5aeb45e1b6984aef2523b41a929d5c7bf3e90df94442c8dc7c5ca8f4366c0aa5feb46e7cb4695171fd922bb0bbe4074cc7bed3cc69f1694f9f48a93b68499370
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "grape"
|
|
4
|
+
|
|
5
|
+
require_relative "../file_based"
|
|
6
|
+
|
|
7
|
+
module Ratalada
|
|
8
|
+
module Contrib
|
|
9
|
+
module Router
|
|
10
|
+
module FileBased
|
|
11
|
+
# The Grape half of file-based routing. Each route file is built the way
|
|
12
|
+
# `Server.run`'s block is — class_eval'd into a Grape::API subclass, file
|
|
13
|
+
# contents in place of the block — and Grape then re-hosts that built API
|
|
14
|
+
# under the prefix its path spelled:
|
|
15
|
+
#
|
|
16
|
+
# parent.mount(file_api => "/teams/:team/settings")
|
|
17
|
+
#
|
|
18
|
+
# `mount` is Grape's own (DSL::Routing#mount): it stores the mount path on
|
|
19
|
+
# the child's settings and replays its endpoints into the parent, so the
|
|
20
|
+
# file's `get "/members"` compiles as "/teams/:team/settings/members" with
|
|
21
|
+
# :team in params. Nothing about Grape's path handling is reimplemented.
|
|
22
|
+
module GrapeAdapter
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Returns the Grape::API subclass with every route file mounted.
|
|
26
|
+
def build(directory)
|
|
27
|
+
Class.new(::Grape::API).tap do |app|
|
|
28
|
+
FileBased.build_map(directory).each do |prefix, paths|
|
|
29
|
+
app.mount(build_file(paths) => mount_path(prefix))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# One route file and the layouts above it, built exactly like a
|
|
35
|
+
# Server.run block: the layouts' `helpers`, `before` blocks and `use`
|
|
36
|
+
# calls land in the same API as the file's own routes.
|
|
37
|
+
def build_file(paths)
|
|
38
|
+
Class.new(::Grape::API).tap do |api|
|
|
39
|
+
paths.each { |path| api.class_eval(File.read(path), path, 1) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A root file has no prefix; Grape spells that mount point "/".
|
|
44
|
+
def mount_path(prefix)
|
|
45
|
+
case prefix
|
|
46
|
+
when "" then "/"
|
|
47
|
+
else prefix
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hanami/api"
|
|
4
|
+
|
|
5
|
+
require_relative "../file_based"
|
|
6
|
+
|
|
7
|
+
module Ratalada
|
|
8
|
+
module Contrib
|
|
9
|
+
module Router
|
|
10
|
+
module FileBased
|
|
11
|
+
# The Hanami half of file-based routing. Hanami::API compiles routes
|
|
12
|
+
# straight into the router's trie, so there is no route list to rewrite
|
|
13
|
+
# afterwards the way Sinatra's Mustermann patterns can be — what it has
|
|
14
|
+
# instead is `scope`, the prefix its own router applies while routes are
|
|
15
|
+
# being defined (Hanami::Router#scope). So the file is evaluated inside
|
|
16
|
+
# the scope its path spells, which is the same result one step earlier:
|
|
17
|
+
#
|
|
18
|
+
# # app/teams/[team]/settings/index.rb
|
|
19
|
+
# get "/members" do
|
|
20
|
+
# "members of #{params[:team]}"
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# becomes "/teams/:team/settings/members". The file's blocks still run in
|
|
24
|
+
# the app's own Block::Context, so `params`, `halt` and any `helpers` are
|
|
25
|
+
# the ones the app defines.
|
|
26
|
+
module HanamiAdapter
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Returns the rack app: a Hanami::API subclass instance, as the Hanami
|
|
30
|
+
# frontend builds it, with every route file evaluated into it.
|
|
31
|
+
def build(directory)
|
|
32
|
+
app = Class.new(::Hanami::API)
|
|
33
|
+
|
|
34
|
+
FileBased.build_map(directory).each do |prefix, paths|
|
|
35
|
+
mount_route_file(app, prefix, paths)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
app.new
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The layouts above the file are evaluated in the same scope, ahead of
|
|
42
|
+
# it, so what they add (`use`, extra routes) applies to that prefix.
|
|
43
|
+
def mount_route_file(app, prefix, paths)
|
|
44
|
+
sources = paths.map { |path| [File.read(path), path] }
|
|
45
|
+
|
|
46
|
+
# `scope` instance_evals its block on the router, so the file's own
|
|
47
|
+
# `get`/`post` calls are the router's, under the prefix.
|
|
48
|
+
app.router.scope(prefix) do
|
|
49
|
+
sources.each { |source, path| instance_eval(source, path, 1) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mustermann"
|
|
4
|
+
require "rack"
|
|
5
|
+
require "sinatra/base"
|
|
6
|
+
|
|
7
|
+
require_relative "../file_based"
|
|
8
|
+
|
|
9
|
+
module Ratalada
|
|
10
|
+
module Contrib
|
|
11
|
+
module Router
|
|
12
|
+
module FileBased
|
|
13
|
+
# The Sinatra half of file-based routing: each route file is built the
|
|
14
|
+
# same way `Server.run`'s block is — class_eval'd into a Sinatra::Base
|
|
15
|
+
# subclass — with the file's contents as the input instead of a block. So
|
|
16
|
+
# a file is ordinary Sinatra:
|
|
17
|
+
#
|
|
18
|
+
# # app/teams/[team]/settings/index.rb
|
|
19
|
+
# get "/members" do
|
|
20
|
+
# "members of #{params[:team]}"
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# Building it yields an app object whose compiled routes are Mustermann
|
|
24
|
+
# patterns, and those are what the prefix from FileBased gets prepended
|
|
25
|
+
# to — Mustermann concatenates patterns itself, so "/teams/:team" +
|
|
26
|
+
# "/members" stays one pattern with both captures.
|
|
27
|
+
#
|
|
28
|
+
# The rewritten apps are then chained: a Sinatra app used as rack
|
|
29
|
+
# middleware forwards the request onward when none of its own routes
|
|
30
|
+
# matched (Base#route_missing), so the first file that spells the path
|
|
31
|
+
# answers and the last one falls through to a plain 404.
|
|
32
|
+
module SinatraAdapter
|
|
33
|
+
NOT_FOUND = lambda do |_env|
|
|
34
|
+
[404, { "content-type" => "text/plain" }, ["Not Found"]]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
def build(directory)
|
|
40
|
+
apps = FileBased.build_map(directory).map do |prefix, paths|
|
|
41
|
+
prefix_routes(build_file(paths), prefix)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# ponytail: one Sinatra app (and its middleware stack) per file, walked
|
|
45
|
+
# in order per request. Collapse into one app class if a fat tree ever
|
|
46
|
+
# shows up in a profile.
|
|
47
|
+
::Rack::Builder.new.tap do |builder|
|
|
48
|
+
apps.each { |app| builder.use(app) }
|
|
49
|
+
builder.run(NOT_FOUND)
|
|
50
|
+
end.to_app
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# One route file and the layouts above it, built exactly like a
|
|
54
|
+
# Server.run block: the layouts' `helpers`, `before` filters and `use`
|
|
55
|
+
# calls land in the same class as the file's own routes.
|
|
56
|
+
def build_file(paths)
|
|
57
|
+
Class.new(::Sinatra::Base).tap do |app|
|
|
58
|
+
paths.each { |path| app.class_eval(File.read(path), path, 1) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Rewrites every route the built app holds to sit under `prefix`.
|
|
63
|
+
def prefix_routes(app, prefix)
|
|
64
|
+
pattern = ::Mustermann.new(prefix, type: :sinatra)
|
|
65
|
+
|
|
66
|
+
app.routes.each_value do |signatures|
|
|
67
|
+
signatures.map! do |(route, conditions, wrapper)|
|
|
68
|
+
[nest(pattern, route), conditions, wrapper]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
app
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A file's own "/" is the prefix itself, not the prefix with a trailing
|
|
76
|
+
# slash, so it is the one pattern that replaces rather than extends —
|
|
77
|
+
# as is any route in a file that sits at the root, where the prefix is
|
|
78
|
+
# empty.
|
|
79
|
+
def nest(prefix, route)
|
|
80
|
+
case [prefix.to_s, route.to_s]
|
|
81
|
+
in ["", _] then route
|
|
82
|
+
in [_, "/"] then prefix
|
|
83
|
+
else prefix + route
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ratalada
|
|
4
|
+
module Contrib
|
|
5
|
+
module Router
|
|
6
|
+
# Turns an expo-router style directory of route files into an ordered map
|
|
7
|
+
# of `prefix => path`. The prefix is what the file's own routes hang off:
|
|
8
|
+
# a file says `get "/members" do ... end` and the adapter mounts that under
|
|
9
|
+
# the prefix its path spells, so each adapter only has to decide how it
|
|
10
|
+
# loads a file and how it spells a placeholder.
|
|
11
|
+
#
|
|
12
|
+
# Ratalada::Contrib::Router::FileBased.build_map("app")
|
|
13
|
+
# # => { "" => "app/index.rb",
|
|
14
|
+
# # "/organizations/:slug" => "app/organizations/[slug]/index.rb", ... }
|
|
15
|
+
#
|
|
16
|
+
# FileBased.join("/organizations/:slug", "/users") # => "/organizations/:slug/users"
|
|
17
|
+
# FileBased.join("/organizations/:slug", "/") # => "/organizations/:slug"
|
|
18
|
+
# FileBased.join("", "/") # => "/"
|
|
19
|
+
#
|
|
20
|
+
# Conventions, all borrowed from expo-router:
|
|
21
|
+
#
|
|
22
|
+
# index.rb the directory itself app/teams/index.rb => /teams
|
|
23
|
+
# [slug].rb a single dynamic segment => /:slug
|
|
24
|
+
# [...rest].rb a catch-all => /*rest
|
|
25
|
+
# (group)/ grouping only, not a segment app/(marketing)/docs.rb => /docs
|
|
26
|
+
# +not-found.rb the fallback => /*
|
|
27
|
+
# _layout.rb not a route of its own: its contents are evaluated
|
|
28
|
+
# into every route file below it, outermost first, so
|
|
29
|
+
# a layout is whatever the file could have written for
|
|
30
|
+
# itself (helpers, filters, middleware, extra routes).
|
|
31
|
+
# Any other `_` file or directory is ignored outright.
|
|
32
|
+
#
|
|
33
|
+
# A map value is the list of files to evaluate, in order: the `_layout.rb`
|
|
34
|
+
# of each directory from the root down, then the route file itself.
|
|
35
|
+
#
|
|
36
|
+
# The map is ordered most specific first (static segments before dynamic,
|
|
37
|
+
# dynamic before catch-all, +not-found last), so an adapter can define the
|
|
38
|
+
# routes in iteration order under a first-match-wins router.
|
|
39
|
+
module FileBased
|
|
40
|
+
NOT_FOUND = "+not-found"
|
|
41
|
+
LAYOUT = "_layout.rb"
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
def build_map(directory, placeholder: ":%s", catch_all: "*%s")
|
|
46
|
+
root = File.expand_path(directory)
|
|
47
|
+
|
|
48
|
+
entries = Dir.glob("**/*.rb", base: root).filter_map do |relative|
|
|
49
|
+
segments = route_segments(relative)
|
|
50
|
+
|
|
51
|
+
unless segments.nil?
|
|
52
|
+
[pattern(segments, placeholder, catch_all), files(root, relative), segments]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
entries
|
|
57
|
+
.sort_by { |(pattern, _files, segments)| [specificity(segments), pattern] }
|
|
58
|
+
.to_h { |(pattern, paths, _segments)| [pattern, paths] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The route file, with the `_layout.rb` of every directory above it in
|
|
62
|
+
# front — outermost first, so an inner layout is evaluated last and can
|
|
63
|
+
# override what an outer one set up.
|
|
64
|
+
def files(root, relative)
|
|
65
|
+
directories = relative.split("/")[0..-2]
|
|
66
|
+
|
|
67
|
+
layouts = directories.each_index.map do |index|
|
|
68
|
+
File.join(root, *directories[0..index], LAYOUT)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
[File.join(root, LAYOUT), *layouts].select { |path| File.file?(path) }
|
|
72
|
+
.push(File.join(root, relative))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The route segments of a file, or nil when the file is not a route.
|
|
76
|
+
def route_segments(relative)
|
|
77
|
+
unless relative.split("/").any? { |segment| segment.start_with?("_") }
|
|
78
|
+
"/#{relative.delete_suffix(".rb")}"
|
|
79
|
+
.delete_suffix("/index")
|
|
80
|
+
.split("/")
|
|
81
|
+
.drop(1)
|
|
82
|
+
.reject { |segment| segment.match?(/\A\(.*\)\z/) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def pattern(segments, placeholder, catch_all)
|
|
87
|
+
spelled = segments.map do |segment|
|
|
88
|
+
case segment
|
|
89
|
+
when /\A\[\.\.\.(.+)\]\z/ then format(catch_all, Regexp.last_match(1))
|
|
90
|
+
when /\A\[(.+)\]\z/ then format(placeholder, Regexp.last_match(1))
|
|
91
|
+
when NOT_FOUND then format(catch_all, "unmatched")
|
|
92
|
+
else segment
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
spelled.map { |segment| "/#{segment}" }.join
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Hangs one of a file's own routes off the prefix its path spelled.
|
|
100
|
+
def join(prefix, route)
|
|
101
|
+
"#{prefix}#{route.delete_suffix("/")}".sub(/\A\z/, "/")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Sorts static segments ahead of dynamic ones at the same depth, and the
|
|
105
|
+
# +not-found fallback behind everything.
|
|
106
|
+
def specificity(segments)
|
|
107
|
+
segments.map do |segment|
|
|
108
|
+
case segment
|
|
109
|
+
when NOT_FOUND then 3
|
|
110
|
+
when /\A\[\.\.\..+\]\z/ then 2
|
|
111
|
+
when /\A\[.+\]\z/ then 1
|
|
112
|
+
else 0
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ratalada-contrib
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0
|
|
4
|
+
version: 2.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nathan K
|
|
@@ -41,6 +41,10 @@ files:
|
|
|
41
41
|
- lib/ratalada/contrib/inertia/helpers.rb
|
|
42
42
|
- lib/ratalada/contrib/inertia/middleware.rb
|
|
43
43
|
- lib/ratalada/contrib/inertia/response.rb
|
|
44
|
+
- lib/ratalada/contrib/router/file_based.rb
|
|
45
|
+
- lib/ratalada/contrib/router/file_based/grape_adapter.rb
|
|
46
|
+
- lib/ratalada/contrib/router/file_based/hanami_adapter.rb
|
|
47
|
+
- lib/ratalada/contrib/router/file_based/sinatra_adapter.rb
|
|
44
48
|
- lib/ratalada/contrib/version.rb
|
|
45
49
|
- lib/ratalada/contrib/vite.rb
|
|
46
50
|
homepage: https://github.com/n-at-han-k/ratalada
|