Skip to content

Commit 3f8a987

Browse files
committed
Reimplement controller extensions:
- dispatch_by_host - dispatch_by_http_methodto
1 parent 0fd9e89 commit 3f8a987

24 files changed

Lines changed: 343 additions & 136 deletions

TODO.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
- [ ] Controllers
44
- [ ] Streamline names of ready-made control methods:
5-
- [ ] dispatch_by_host('.', mapping:) - currently `route_by_hosst`
6-
- [ ] dispatch_by_http_method - currently `http_methods`
75
- [ ] dispatch_json_rpc
86

97
- [ ] Collection - treat directories and files as collections of data.

examples/blog/app/posts/[id]/edit.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
@posts = import '/_lib/posts'
22
@layout = import '/_layout/default'
33

4-
export http_methods
4+
export dispatch_by_http_method
55

66
def get(req)
77
id = req.route_params['id'].to_i

examples/blog/app/posts/[id]/index.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
@posts = import '/_lib/posts'
22
@layout = import '/_layout/default'
33

4-
export http_methods
4+
export dispatch_by_http_method
55

66
def get(req)
77
id = req.route_params['id'].to_i

examples/blog/app/posts/index.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
@posts = import '_lib/posts'
22
@layout = import '_layout/default'
33

4-
export http_methods
4+
export dispatch_by_http_method
55

66
def get(req)
77
posts = @posts.get_all

examples/blog/app/posts/new.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
@posts = import '/_lib/posts'
22
@layout = import '/_layout/default'
33

4-
export http_methods
4+
export dispatch_by_http_method
55

66
def get(req)
77
req.respond_html(

lib/syntropy.rb

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,12 @@
1717
require 'syntropy/routing_tree'
1818
require 'syntropy/json_api'
1919
require 'syntropy/side_run'
20-
require 'syntropy/utils'
2120
require 'syntropy/version'
2221

2322
# Syntropy is a web framework for building web apps in Ruby. Syntropy uses
2423
# UringMachine for I/O and concurrency, and provides a comprehensive and
2524
# flexible solution for writing web apps with minimal boilerplate.
2625
module Syntropy
27-
extend Utilities
28-
2926
class << self
3027
attr_accessor :machine, :dev_mode, :test_mode
3128

lib/syntropy/app.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
require 'syntropy/module_loader'
1010
require 'syntropy/routing_tree'
1111
require 'syntropy/mime_types'
12+
require 'syntropy/controller_extensions'
1213

1314
module Syntropy
1415
# The App implements a Syntropy application. It is responsible for handling
@@ -34,7 +35,7 @@ def site_file_app(env)
3435
fn = File.join(env[:app_root], '_site.rb')
3536
return nil if !File.file?(fn)
3637

37-
loader = Syntropy::ModuleLoader.new(env)
38+
loader = Syntropy::ModuleLoader.new(env, extensions: ControllerExtensions)
3839
loader.load('_site')
3940
end
4041

@@ -61,7 +62,10 @@ def initialize(**env)
6162
@env = env
6263
@logger = env[:logger]
6364

64-
@module_loader = Syntropy::ModuleLoader.new(app: self, **env)
65+
@module_loader = Syntropy::ModuleLoader.new(
66+
env.merge(app: self),
67+
extensions: ControllerExtensions
68+
)
6569
setup_routing_tree
6670
start
6771
end
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# frozen_string_literal: true
2+
3+
require 'securerandom'
4+
5+
module Syntropy
6+
# Utilities for use in modules
7+
module ControllerExtensions
8+
# Returns a unique temporary path
9+
#
10+
# @param prefix [String] temp file prefix
11+
# @return [String]
12+
def tmp_path(prefix = 'syntropy')
13+
"/tmp/#{prefix}-#{SecureRandom.hex(16)}"
14+
end
15+
16+
# Returns a request handler that routes request according to the host
17+
# header. Looks for site directories (named by host name) in the app's root
18+
# directory. A map may be given in order to provide additional hostnames to
19+
# site directories.
20+
#
21+
# @param dir [String, nil] relative directory path for host sites
22+
# @param map [Hash, nil] hash mapping host names to relative site directory
23+
# @return [Proc] router proc
24+
def dispatch_by_host(dir = nil, map = nil)
25+
raise Syntropy::Error, 'Must provide dir and/or map' if !dir && !map
26+
27+
site_map = {}
28+
setup_directory_sites(dir, site_map) if dir
29+
setup_mapped_sites(map, site_map) if map
30+
31+
->(req) do
32+
site = site_map[req.host]
33+
site ? site.call(req) : req.respond(nil, ':status' => HTTP::BAD_REQUEST)
34+
end
35+
end
36+
37+
# Returns a request handler that handles requests by calling the appropriate
38+
# module method (e.g. get, post, etc.)
39+
#
40+
# @return [Proc]
41+
def dispatch_by_http_method
42+
->(req) do
43+
route_by_http_method(req)
44+
end
45+
end
46+
47+
# Returns a list of parsed markdown pages at the given path.
48+
#
49+
# @param env [Hash] app environment hash
50+
# @param ref [String] directory path
51+
# @return [Array<Hash>] array of page entries
52+
def page_list(env, ref)
53+
full_path = File.join(env[:app_root], ref)
54+
raise 'Not a directory' if !File.directory?(full_path)
55+
56+
Dir[File.join(full_path, '*.md')].sort.map {
57+
atts, markdown = Syntropy::Markdown.parse(it, env)
58+
{ atts:, markdown: }
59+
}
60+
end
61+
62+
# Instantiates a Syntropy app for the given environment hash.
63+
#
64+
# @return [Syntropy::App]
65+
def app(**)
66+
Syntropy::App.new(**)
67+
end
68+
69+
BUILTIN_APPLET_app_root = File.expand_path(File.join(__dir__, 'applets/builtin'))
70+
71+
# Creates a builtin applet with the given environment hash. By default the
72+
# builtin applet is mounted at /.syntropy.
73+
#
74+
# @param env [Hash] app environment
75+
# @param mount_path [String] mount path for the builtin applet
76+
# @return [Syntropy::App] applet
77+
def builtin_applet(env, mount_path: '/.syntropy')
78+
app(
79+
machine: env[:machine],
80+
app_root: BUILTIN_APPLET_app_root,
81+
mount_path: mount_path,
82+
builtin_applet_path: nil,
83+
watch_files: nil
84+
)
85+
end
86+
87+
private
88+
89+
# Finds sites in the root directory for the given environment hash, adds
90+
# entries to the given site map.
91+
#
92+
# @param dir [String] relative or absolute path
93+
# @param site_map [Hash] site map
94+
# @return [void]
95+
def setup_directory_sites(ref, site_map)
96+
app_root = @app ? @app.app_root : @env[:app_root]
97+
ref = normalize_import_ref(ref)
98+
99+
Dir[File.join(app_root, ref, '*')]
100+
.select { File.directory?(it) && File.basename(it) !~ /^_/ }
101+
.each { |entry| site_map[File.basename(entry)] = make_app(entry) }
102+
end
103+
104+
# converts the given map entries by adding entries to the given site map.
105+
#
106+
# @param map [Hash] ref map
107+
# @param site_map [Hash] site map
108+
# @return [void]
109+
def setup_mapped_sites(map, site_map)
110+
app_root = @app ? @app.app_root : @env[:app_root]
111+
map.each do |name, ref|
112+
ref = File.join(File.dirname(@ref), ref) if ref !~ /^\//
113+
site_root = File.join(app_root, ref)
114+
site_map[name] = make_app(site_root)
115+
end
116+
end
117+
118+
# Creates an app loaded from the given root directory, with the present
119+
# mount path.
120+
def make_app(site_root)
121+
mount_path = @ref == '/_site' ? '/' : @ref
122+
env = @env.merge(app_root: site_root, mount_path:)
123+
Syntropy::App.new(**env)
124+
end
125+
126+
# Handles the given request by calling the module method corresponding to
127+
# the request's HTTP method. If no method is found, raises a
128+
# method_not_allowed error.
129+
def route_by_http_method(req)
130+
sym = req.method.to_sym
131+
raise Syntropy::Error.method_not_allowed if !respond_to?(sym)
132+
133+
send(sym, req)
134+
end
135+
end
136+
end

lib/syntropy/module_loader.rb

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require 'papercraft'
4+
require 'syntropy/errors'
45

56
module Syntropy
67
# The ModuleLoader class implemenets a module loader. It handles loading of
@@ -25,12 +26,14 @@ class ModuleLoader
2526
# Instantiates a module loader
2627
#
2728
# @param env [Hash] environment hash
29+
# @param extensions [Module, Array<Module>] extension module(s)
2830
# @return [void]
29-
def initialize(env)
31+
def initialize(env, extensions: nil)
3032
@env = env
3133
@app_root = env[:app_root]
3234
@modules = {} # maps ref to module entry
3335
@fn_map = {} # maps filename to ref
36+
@extensions = extensions
3437
end
3538

3639
# Loads a module (if not already loaded) and returns its export value.
@@ -131,7 +134,7 @@ def load_module(ref, raise_on_missing: true)
131134
@fn_map[fn] = ref
132135
code = IO.read(fn)
133136
env = @env.merge(module_loader: self, ref: clean_ref(ref))
134-
mod = Syntropy::ModuleContext.load(env, code, fn)
137+
mod = Syntropy::ModuleContext.load(env, code, fn, @extensions)
135138
add_dependencies(ref, mod.__dependencies__)
136139
export_value = transform_module_export_value(
137140
mod.__export_value__, fn, raise_on_missing:
@@ -191,16 +194,38 @@ def transform_module_export_value(export_value, fn, raise_on_missing:)
191194
# module.
192195
class ModuleContext
193196
# Loads a module, returning the module instance
194-
def self.load(env, code, fn)
195-
m = new(env)
196-
m.instance_eval(code, fn)
197+
# @param env [Hash] app environment
198+
# @param code [String] module source code
199+
# @param fn [String] module file name
200+
# @param extensions [Module, Array<Module>] extension module(s)
201+
# @return [Syntropy::ModuleContext] created module context
202+
def self.load(env, code, fn, extensions)
203+
mod = new(env)
204+
apply_extensions(mod, extensions)
205+
mod.instance_eval(code, fn)
197206
env[:logger]&.info(message: "Loaded module at #{fn}")
198-
m
207+
mod
199208
rescue StandardError, SyntaxError => e
200209
env[:logger]&.error(message: "Error while loading module at #{fn}", error: e)
201210
e.is_a?(SyntaxError) ? handle_syntax_error(env, e) : (raise e)
202211
end
203212

213+
# Applies the given extension(s) to the given module context.
214+
#
215+
# @param mod [Syntropy::ModuleContext] module context
216+
# @param extensions [Module, Array<Module>] extension module(s)
217+
def self.apply_extensions(mod, extensions)
218+
case extensions
219+
when Array
220+
extensions.each { mod.extend(it) }
221+
when Module
222+
mod.extend(extensions)
223+
when nil # return
224+
else
225+
raise Syntropy::Error, "Invalid module extensions: #{extensions.inspect}"
226+
end
227+
end
228+
204229
# Initializes a module with the given environment hash.
205230
#
206231
# @param env [Hash] environment hash
@@ -317,24 +342,6 @@ def app(**env)
317342
Syntropy::App.new(**env)
318343
end
319344

320-
# Returns a request handler that handles requests by calling the appropriate
321-
# module method (e.g. get, post, etc.)
322-
#
323-
# @return [Proc]
324-
def http_methods
325-
->(req) { route_by_http_method(req) }
326-
end
327-
328-
# Handles the given request by calling the module method corresponding to
329-
# the request's HTTP method. If no method is found, raises a
330-
# method_not_allowed error.
331-
def route_by_http_method(req)
332-
sym = req.method.to_sym
333-
raise Syntropy::Error.method_not_allowed if !respond_to?(sym)
334-
335-
send(sym, req)
336-
end
337-
338345
def handle_syntax_error(env, e)
339346
$stderr.puts("\n#{e.message}") if !Syntropy.test_mode
340347
m = e.message.match(/^(.+): syntax/)

lib/syntropy/test.rb

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,9 @@ module Syntropy
1111
class Test < Minitest::Test
1212
HTTP = Syntropy::HTTP
1313

14-
# Sets the app environment for all Syntropy tests.
15-
#
16-
# @param env [Hash] app environment hash
17-
# @return [void]
18-
def self.env=(env)
19-
@@env = env
14+
class << self
15+
# Gets/sets app environment for tests
16+
attr_accessor :env
2017
end
2118

2219
attr_reader :machine, :app
@@ -25,7 +22,7 @@ def self.env=(env)
2522
#
2623
# @return [Hash] test app environment
2724
def env
28-
@@env
25+
self.class.env
2926
end
3027

3128
# Loads and returns a module with the given reference.
@@ -101,17 +98,38 @@ def post_form(path, data, **)
10198
post(path, 'application/x-www-form-urlencoded', URI.encode_www_form(data), **)
10299
end
103100

101+
# Makes an HTTP PATCH request to the test app.
102+
#
103+
# @param path [String] request path
104+
# @param content_type [String, nil] content MIME type
105+
# @param body [String] request body
106+
# @param headers [Hash] request headers
107+
# @return [Syntropy::Request]
108+
def patch(path, content_type, body, **headers)
109+
headers = headers.merge('content-type' => content_type) if content_type
110+
http_request(
111+
headers.merge(
112+
{
113+
':method' => 'PATCH',
114+
':path' => path
115+
}
116+
),
117+
body
118+
)
119+
end
120+
104121
# Sets up a test instance.
105122
#
106123
# @return [void]
107124
def setup
108-
raise 'Environment not set' if !@@env
125+
env = self.class.env
126+
raise 'Environment not set' if !env
109127

110-
Syntropy.load_config(@@env)
128+
Syntropy.load_config(env)
111129

112130
@machine = UM.new
113131
@app = Syntropy::App.new(
114-
**@@env.merge(
132+
**env.merge(
115133
machine: @machine,
116134
test_mode: true
117135
)

0 commit comments

Comments
 (0)