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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d30cdd4308ec4495bd965180994b34d26439ba7f0127398562eb381cc8eccc95
4
- data.tar.gz: bec8c448d5e15fb2b25478f1cb31e299514fdd2ef9067c81681244e54a0bdae1
3
+ metadata.gz: 684119c7ffaadc33fd820a4afad88d7c814e20d95cb397e3b7f200a48b89c252
4
+ data.tar.gz: de2d2c52778288e334bb95a5bb27420e062714d35924a8db5ab98d4b15491f93
5
5
  SHA512:
6
- metadata.gz: e72898aabde80621c44d55d2f12a13dcd9210d65c5690aaa444f0b867b81354300e2c6904fbb60e6ddf9c97a784b918616c8dfd3c45ad55ffd84df5fb2d4e19f
7
- data.tar.gz: 97151f7ad7867f3856c7da8971b2d9b63318716bb10319f9865a7c12b92dc61c1559756d5a50db14c4c846ed9f777307e091629545e7ad8a68d676c860636b6d
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ratalada
4
4
  module Contrib
5
- VERSION = "2.0.1"
5
+ VERSION = "2.1.0"
6
6
  end
7
7
  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.1
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