ratalada-contrib 2.1.0 → 3.0.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: 684119c7ffaadc33fd820a4afad88d7c814e20d95cb397e3b7f200a48b89c252
4
- data.tar.gz: de2d2c52778288e334bb95a5bb27420e062714d35924a8db5ab98d4b15491f93
3
+ metadata.gz: cd2d16bc3b97e31abe859281ecc0ba7a322a6bce7dc1a71e854a21d80e470fdb
4
+ data.tar.gz: 4f4139c69c3183ddae2b4c2df734424e0a18be4d31b10f937491be9718753156
5
5
  SHA512:
6
- metadata.gz: c22ec36581b747dbeccacca95a0ae3d492286682365c5b79d234bfc9ad5dbaed4fb9fd9af5fe1f18933f7823109ce887bde7b6da6f3742636e11e9d91fdd743f
7
- data.tar.gz: 5aeb45e1b6984aef2523b41a929d5c7bf3e90df94442c8dc7c5ca8f4366c0aa5feb46e7cb4695171fd922bb0bbe4074cc7bed3cc69f1694f9f48a93b68499370
6
+ metadata.gz: 601080c5ec4fff081b714aaca9d6499bea7eda3d97ef1cfd7f5ea355e00178094c81d52921024d414bf07663fd4090c37d3e943b40bf53c52977629cd85b92f4
7
+ data.tar.gz: 66c6a9b432555f7d0a542f1eed3d364d108cdb2fbb9b2f4e6cc2f414020997369e2a4ac3518f05402400e09a9cfd3c39c8f7a9bcad4825214966c5635065d661
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mustermann"
4
+ require "mustermann/ast/pattern"
5
+
6
+ module Mustermann
7
+ # Expo Router style pattern implementation.
8
+ #
9
+ # @example
10
+ # Mustermann.new('/[foo]', type: :expo) === '/bar' # => true
11
+ #
12
+ # @see Mustermann::Pattern
13
+ class Expo < AST::Pattern
14
+ register :expo
15
+
16
+ NOT_FOUND = "+not-found"
17
+ NOT_FOUND_CAPTURE = "unmatched"
18
+
19
+ # Turns an Expo Router file path into the route it serves: file extensions
20
+ # and the +api suffix are dropped, a _layout file stands for its directory,
21
+ # group segments are invisible and a trailing index is its directory.
22
+ # Mirrors getContextKey/stripInvisibleSegmentsFromPath in expo-router.
23
+ # @!visibility private
24
+ def self.route(string)
25
+ string = string.sub(%r{\A(?:\.\.?/|/)+}, "")
26
+ string = string.sub(/(\+api)?\.rb$/, "").sub(%r{/?_layout$}, "")
27
+ segments = (?/ + string.delete_prefix(?/)).split(?/, -1)
28
+ if segments.last == "index"
29
+ segments.pop
30
+ end
31
+ segments.reject! { |s| s =~ /\A\(.+\)\z/ }
32
+ if segments.last == NOT_FOUND
33
+ segments[-1] = "[...#{NOT_FOUND_CAPTURE}]"
34
+ end
35
+ segments.join(?/).then { |route| route.empty? ? ?/ : route }
36
+ end
37
+
38
+ def initialize(string, **options)
39
+ super(Expo.route(string), **options)
40
+ end
41
+
42
+ on(nil, ?]) { |c| unexpected(c) }
43
+
44
+ on(?[) do |char|
45
+ name = expect(%r{[^\[\]/]+}, char: char)
46
+ expect(?])
47
+
48
+ if name.start_with?("...")
49
+ node(:named_splat, name[3..], constraint: ".+", convert: ->(e) { e.split(?/) })
50
+ else
51
+ node(:capture, name)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "grape"
3
+ require "ratalada/grape"
4
4
 
5
5
  require_relative "../file_based"
6
6
 
@@ -8,47 +8,26 @@ module Ratalada
8
8
  module Contrib
9
9
  module Router
10
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.
11
+ # Teaches Grape `route_prefix`, the same way as Sinatra: the paths of
12
+ # every route defined while it is set hang off it.
22
13
  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
14
+ def route(methods, paths = ["/"], route_options = {}, &block)
15
+ unless route_prefix.to_s.empty?
16
+ paths = Array(paths).map { |path| FileBased.join(route_prefix, path.to_s) }
31
17
  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
18
 
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
19
+ super
49
20
  end
50
21
  end
51
22
  end
52
23
  end
53
24
  end
54
25
  end
26
+
27
+ class Grape::API::Instance # rubocop:disable Style/ClassAndModuleChildren
28
+ class << self
29
+ attr_accessor :route_prefix
30
+
31
+ prepend Ratalada::Contrib::Router::FileBased::GrapeAdapter
32
+ end
33
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "hanami/api"
3
+ require "ratalada/hanami"
4
4
 
5
5
  require_relative "../file_based"
6
6
 
@@ -8,49 +8,17 @@ module Ratalada
8
8
  module Contrib
9
9
  module Router
10
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.
11
+ # Teaches Hanami::API `route_prefix`. Hanami's router already carries a
12
+ # path prefix it is what `scope` sets for the length of its block —
13
+ # so this is that, set until the next file.
26
14
  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
15
+ def route_prefix=(prefix)
16
+ @router.instance_variable_set(:@path_prefix, ::Hanami::Router::Prefix.new("/").join(prefix.to_s))
51
17
  end
52
18
  end
53
19
  end
54
20
  end
55
21
  end
56
22
  end
23
+
24
+ Hanami::API.extend(Ratalada::Contrib::Router::FileBased::HanamiAdapter)
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "mustermann"
4
- require "rack"
5
- require "sinatra/base"
3
+ require "ratalada/sinatra"
6
4
 
7
5
  require_relative "../file_based"
8
6
 
@@ -10,81 +8,27 @@ module Ratalada
10
8
  module Contrib
11
9
  module Router
12
10
  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.
11
+ # Teaches Sinatra `route_prefix`: every route defined while it is set
12
+ # hangs off it, which is how a route file's own `get "/members"` lands
13
+ # under the prefix its path spelled.
32
14
  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
15
+ def route(verb, path, options = {}, &block)
16
+ unless route_prefix.to_s.empty?
17
+ path = FileBased.join(route_prefix, path)
70
18
  end
71
19
 
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
20
+ super
85
21
  end
86
22
  end
87
23
  end
88
24
  end
89
25
  end
90
26
  end
27
+
28
+ class Sinatra::Base # rubocop:disable Style/ClassAndModuleChildren
29
+ set :route_prefix, ""
30
+
31
+ class << self
32
+ prepend Ratalada::Contrib::Router::FileBased::SinatraAdapter
33
+ end
34
+ end
@@ -1,66 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ratalada"
4
+
5
+ require_relative "file_based/expo"
6
+
3
7
  module Ratalada
4
8
  module Contrib
5
9
  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
10
  module FileBased
40
- NOT_FOUND = "+not-found"
41
11
  LAYOUT = "_layout.rb"
42
12
 
43
13
  module_function
44
14
 
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)
15
+ # The app, built by whichever Ratalada frontend is in use: every route
16
+ # file is evaluated into the frontend's own class, under the prefix its
17
+ # path spells. Requiring the frontend's adapter is what teaches that
18
+ # class `route_prefix`.
19
+ def build(directory)
20
+ map = build_map(directory)
50
21
 
51
- unless segments.nil?
52
- [pattern(segments, placeholder, catch_all), files(root, relative), segments]
22
+ routes = proc do
23
+ map.each do |prefix, paths|
24
+ self.route_prefix = prefix
25
+ paths.each { |path| class_eval(File.read(path), path, 1) }
53
26
  end
54
27
  end
55
28
 
56
- entries
57
- .sort_by { |(pattern, _files, segments)| [specificity(segments), pattern] }
58
- .to_h { |(pattern, paths, _segments)| [pattern, paths] }
29
+ Ratalada.frontend.build(routes)
30
+ end
31
+
32
+ # A hash of route => the files that serve it, ordered most specific
33
+ # first. Mustermann::Expo does the file path => route translation; all
34
+ # this adds is which files to evaluate and how the adapter spells a
35
+ # placeholder.
36
+ #
37
+ # {
38
+ # "/organisations/:id/edit" => ["./app/organisations/[id]/edit.rb"]
39
+ # }
40
+ def build_map(directory, placeholder: ":%s", catch_all: "*%s")
41
+ root = File.expand_path(directory)
42
+
43
+ Dir.glob("**/*.rb", base: root)
44
+ .reject { |relative| relative.split("/").any? { |segment| segment.start_with?("_") } }
45
+ .map { |relative| [Mustermann::Expo.route(relative), files(root, relative)] }
46
+ .sort_by { |(route, _paths)| [specificity(route), route] }
47
+ .to_h { |(route, paths)| [spell(route, placeholder, catch_all), paths] }
59
48
  end
60
49
 
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
50
  def files(root, relative)
65
51
  directories = relative.split("/")[0..-2]
66
52
 
@@ -68,45 +54,27 @@ module Ratalada
68
54
  File.join(root, *directories[0..index], LAYOUT)
69
55
  end
70
56
 
71
- [File.join(root, LAYOUT), *layouts].select { |path| File.file?(path) }
57
+ [File.join(root, LAYOUT), *layouts]
58
+ .select { |path| File.file?(path) }
72
59
  .push(File.join(root, relative))
73
60
  end
74
61
 
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
62
+ # An expo route in the adapter's own spelling: "/teams/[team]" => "/teams/:team".
63
+ # The root route is "" — a prefix nothing hangs off, not a route of "/".
64
+ def spell(route, placeholder, catch_all)
65
+ route.delete_suffix("/").gsub(%r{\[(\.\.\.)?([^\[\]/]+)\]}) do
66
+ format(Regexp.last_match(1) ? catch_all : placeholder, Regexp.last_match(2))
94
67
  end
95
-
96
- spelled.map { |segment| "/#{segment}" }.join
97
68
  end
98
69
 
99
- # Hangs one of a file's own routes off the prefix its path spelled.
100
70
  def join(prefix, route)
101
71
  "#{prefix}#{route.delete_suffix("/")}".sub(/\A\z/, "/")
102
72
  end
103
73
 
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|
74
+ def specificity(route)
75
+ route.split("/").drop(1).map do |segment|
108
76
  case segment
109
- when NOT_FOUND then 3
77
+ when "[...#{Mustermann::Expo::NOT_FOUND_CAPTURE}]" then 3
110
78
  when /\A\[\.\.\..+\]\z/ then 2
111
79
  when /\A\[.+\]\z/ then 1
112
80
  else 0
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ratalada
4
4
  module Contrib
5
- VERSION = "2.1.0"
5
+ VERSION = "3.0.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.1.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan K
@@ -15,14 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '2.0'
18
+ version: '3.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '2.0'
25
+ version: '3.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: mustermann
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">"
31
+ - !ruby/object:Gem::Version
32
+ version: '2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">"
38
+ - !ruby/object:Gem::Version
39
+ version: '2'
26
40
  description: |
27
41
  Optional add-on modules for ratalada that do not belong in the core gem.
28
42
  Require only the ones you use; each pulls its own framework (the
@@ -42,6 +56,7 @@ files:
42
56
  - lib/ratalada/contrib/inertia/middleware.rb
43
57
  - lib/ratalada/contrib/inertia/response.rb
44
58
  - lib/ratalada/contrib/router/file_based.rb
59
+ - lib/ratalada/contrib/router/file_based/expo.rb
45
60
  - lib/ratalada/contrib/router/file_based/grape_adapter.rb
46
61
  - lib/ratalada/contrib/router/file_based/hanami_adapter.rb
47
62
  - lib/ratalada/contrib/router/file_based/sinatra_adapter.rb