syntropy 0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9f54331b249d5f3f5b113380173af8e6179590a60ea5f9f3461b1c87f2c4ce53
4
+ data.tar.gz: f04553e5a04480e9720319a4b93dacdc38e04990a3e60471cf83fde1eea905eb
5
+ SHA512:
6
+ metadata.gz: 3c749127a9b98be60618f2d82229bcab09894465caebeef37a04672f5456e1480089cbbb9cb662969d024ed64a1657d6e1cbad96af18a2292c2cc5723628e358
7
+ data.tar.gz: a0ab0555fff735b401204ee54f294c6c009d18d527c938da0ec467f82d0e30ede3e6a8bc76615ef2d8ba5fc01fcd6cd060f3e5522aaf13e69cf6c31fcfaf704b
@@ -0,0 +1,34 @@
1
+ name: Tests
2
+
3
+ on: [push, pull_request]
4
+
5
+ concurrency:
6
+ group: tests-${{ format('{0}-{1}', github.head_ref || github.run_number, github.job) }}
7
+ cancel-in-progress: true
8
+
9
+ jobs:
10
+ build:
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ # macos-latest uses arm64, macos-13 uses x86
15
+ os: [ubuntu-latest]
16
+ ruby: ['3.4', 'head']
17
+
18
+ name: ${{matrix.os}}, ${{matrix.ruby}}
19
+
20
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
21
+
22
+ runs-on: ${{matrix.os}}
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ with:
26
+ submodules: recursive
27
+
28
+ - uses: ruby/setup-ruby@v1
29
+ with:
30
+ ruby-version: ${{matrix.ruby}}
31
+ bundler-cache: true # 'bundle install' and cache
32
+ - name: Run tests
33
+ # run: bundle exec ruby test/test_um.rb --name test_read_each_raising_2
34
+ run: bundle exec rake test
data/.gitignore ADDED
@@ -0,0 +1,56 @@
1
+ *.gem
2
+ *.rbc
3
+ /.config
4
+ /coverage/
5
+ /InstalledFiles
6
+ /pkg/
7
+ /spec/reports/
8
+ /spec/examples.txt
9
+ /test/tmp/
10
+ /test/version_tmp/
11
+ /tmp/
12
+
13
+ # Used by dotenv library to load environment variables.
14
+ # .env
15
+
16
+ # Ignore Byebug command history file.
17
+ .byebug_history
18
+
19
+ ## Specific to RubyMotion:
20
+ .dat*
21
+ .repl_history
22
+ build/
23
+ *.bridgesupport
24
+ build-iPhoneOS/
25
+ build-iPhoneSimulator/
26
+
27
+ ## Specific to RubyMotion (use of CocoaPods):
28
+ #
29
+ # We recommend against adding the Pods directory to your .gitignore. However
30
+ # you should judge for yourself, the pros and cons are mentioned at:
31
+ # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
32
+ #
33
+ # vendor/Pods/
34
+
35
+ ## Documentation cache and generated files:
36
+ /.yardoc/
37
+ /_yardoc/
38
+ /doc/
39
+ /rdoc/
40
+
41
+ ## Environment normalization:
42
+ /.bundle/
43
+ /vendor/bundle
44
+ /lib/bundler/man/
45
+
46
+ # for a library or gem, you might want to ignore these files since the code is
47
+ # intended to run in multiple environments; otherwise, check them in:
48
+ Gemfile.lock
49
+ # .ruby-version
50
+ # .ruby-gemset
51
+
52
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
53
+ .rvmrc
54
+
55
+ # Used by RuboCop. Remote config files pulled in from inherit_from directive.
56
+ # .rubocop-https?--*
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ ## 0.1 2025-06-17
2
+
3
+ - Move context inside Request object
4
+ - Implement routing
5
+ - Implement RPC API controller
6
+ - Implement Context with parameter validation
7
+ - Preliminary version
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Sharon Rosner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,105 @@
1
+ <h1 align="center">
2
+ <br>
3
+ Syntropy
4
+ </h1>
5
+
6
+ <h4 align="center">A Web Framework for Ruby</h4>
7
+
8
+ <p align="center">
9
+ <a href="http://rubygems.org/gems/syntropy">
10
+ <img src="https://badge.fury.io/rb/syntropy.svg" alt="Ruby gem">
11
+ </a>
12
+ <a href="https://github.com/noteflakes/syntropy/actions">
13
+ <img src="https://github.com/noteflakes/syntropy/actions/workflows/test.yml/badge.svg" alt="Tests">
14
+ </a>
15
+ <a href="https://github.com/noteflakes/syntropy/blob/master/LICENSE">
16
+ <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License">
17
+ </a>
18
+ </p>
19
+
20
+ ## What is Syntropy?
21
+
22
+ | Syntropy: A tendency towards complexity, structure, order, organization of
23
+ ever more advantageous and orderly patterns.
24
+
25
+ Syntropy is a WIP web framework for building multi-page and single-page apps.
26
+ Syntropy uses file tree-based routing, and provides controllers for a number of
27
+ common patterns, such as a SPA with client-side rendering, a standard
28
+ server-rendered MPA, a REST API etc.
29
+
30
+ ## Routing
31
+
32
+ Routing is performed automatically by following the tree structure of the
33
+ Syntropy app. A simple example:
34
+
35
+ ```
36
+ site/
37
+ ├ _layout/
38
+ | └ default.rb
39
+ ├ _articles/
40
+ | └ 2025-06-01-hello_world.md
41
+ ├ api/
42
+ | └ v1.rb
43
+ ├ assets/
44
+ | ├ css/
45
+ | ├ img/
46
+ | └ js/
47
+ ├ about.md
48
+ ├ archive.rb
49
+ ├ index.rb
50
+ └ robots.txt
51
+ ```
52
+
53
+ The routing follows the file hierarchy, and Syntropy knows how to serve static
54
+ asset files (CSS, JS, images...) as well as render markdown files and run custom
55
+ Ruby code.
56
+
57
+ ## What does a Syntropic Ruby module look like?
58
+
59
+ Consider `archive.rb` in the example above. We want to get a list of articles
60
+ and render it with the given layout:
61
+
62
+ ```ruby
63
+ # archive.rb
64
+ @@layout = import('$layout/default')
65
+
66
+ def articles
67
+ Syntropy.stamped_file_entries('/_articles')
68
+ end
69
+
70
+ @@layout.apply(title: 'archive') {
71
+ div {
72
+ ul {
73
+ articles.each { |article|
74
+ li { a(article.title, href: article.url) }
75
+ }
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ But a module can be something completely different:
82
+
83
+ ```ruby
84
+ # api/v1.rb
85
+ class APIV1 < Syntropy::RPCAPI
86
+ def initialize(db)
87
+ @db = db
88
+ end
89
+
90
+ # /posts
91
+ def all(req)
92
+ @db[:posts].order_by(:stamp.desc).to_a
93
+ end
94
+
95
+ def by_id(req)
96
+ id = req.validate_param(:id, /^{4,32}$/)
97
+ @db[:posts].where(id: id).first
98
+ end
99
+ end
100
+
101
+ APIV1.new(Syntropy.env.open_db)
102
+ ```
103
+
104
+ Basically, the return value of the module is a template or a resource that
105
+ responds to the request.
data/Rakefile ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake/clean'
4
+
5
+ task :default => :test
6
+ task :test do
7
+ exec 'ruby test/run.rb'
8
+ end
9
+
10
+ task :release do
11
+ require_relative './lib/syntropy/version'
12
+ version = Syntropy::VERSION
13
+
14
+ puts 'Building syntropy...'
15
+ `gem build syntropy.gemspec`
16
+
17
+ puts "Pushing syntropy #{version}..."
18
+ `gem push syntropy-#{version}.gem`
19
+
20
+ puts "Cleaning up..."
21
+ `rm *.gem`
22
+ end
data/TODO.md ADDED
@@ -0,0 +1,11 @@
1
+ - More database methods:
2
+
3
+ - `Database#quote`
4
+ - `Database#cache_flush` https://sqlite.org/c3ref/db_cacheflush.html
5
+ - `Database#release_memory` https://sqlite.org/c3ref/db_release_memory.html
6
+
7
+ - Security
8
+
9
+ - Enable extension loading by using
10
+ [SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION](https://www.sqlite.org/c3ref/c_dbconfig_defensive.html#sqlitedbconfigenableloadextension)
11
+ in order to prevent usage of `load_extension()` SQL function.
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'qeweney'
4
+ require 'syntropy/errors'
5
+ require 'json'
6
+ require 'papercraft'
7
+
8
+ module Syntropy
9
+ class App
10
+ attr_reader :route_cache
11
+
12
+ def initialize(src_path, mount_path)
13
+ @src_path = src_path
14
+ @mount_path = mount_path
15
+ @route_cache = {}
16
+
17
+ @relative_path_re = calculate_relative_path_re(mount_path)
18
+ end
19
+
20
+ def find_route(path, cache: true)
21
+ cached = @route_cache[path]
22
+ return cached if cached
23
+
24
+ entry = calculate_route(path)
25
+ if entry[:kind] != :not_found
26
+ @route_cache[path] = entry if cache
27
+ end
28
+ entry
29
+ end
30
+
31
+ def call(req)
32
+ entry = find_route(req.path)
33
+ render_entry(req, entry)
34
+ rescue StandardError => e
35
+ p e
36
+ p e.backtrace
37
+ req.respond(e.message, ':status' => Qeweney::Status::INTERNAL_SERVER_ERROR)
38
+ end
39
+
40
+ private
41
+
42
+ def calculate_relative_path_re(mount_path)
43
+ mount_path = '' if mount_path == '/'
44
+ /^#{mount_path}(?:\/(.*))?$/
45
+ end
46
+
47
+ FILE_KINDS = {
48
+ '.rb' => :module,
49
+ '.md' => :markdown
50
+ }
51
+ NOT_FOUND = { kind: :not_found }
52
+
53
+ # We don't allow access to path with /.., or entries that start with _
54
+ FORBIDDEN_RE = /(\/_)|((\/\.\.)\/?)/
55
+
56
+ def calculate_route(path)
57
+ return NOT_FOUND if path =~ FORBIDDEN_RE
58
+
59
+ m = path.match(@relative_path_re)
60
+ return NOT_FOUND if !m
61
+
62
+ relative_path = m[1] || ''
63
+ fs_path = File.join(@src_path, relative_path)
64
+
65
+ return file_entry(fs_path) if File.file?(fs_path)
66
+ return find_index_entry(fs_path) if File.directory?(fs_path)
67
+
68
+ entry = find_file_entry_with_extension(fs_path)
69
+ return entry if entry[:kind] != :not_found
70
+
71
+ find_up_tree_module(path)
72
+ end
73
+
74
+ def file_entry(fn)
75
+ { fn: fn, kind: FILE_KINDS[File.extname(fn)] || :static }
76
+ end
77
+
78
+ def find_index_entry(dir)
79
+ find_file_entry_with_extension(File.join(dir, 'index'))
80
+ end
81
+
82
+ def find_file_entry_with_extension(path)
83
+ fn = "#{path}.html"
84
+ return file_entry(fn) if File.file?(fn)
85
+
86
+ fn = "#{path}.md"
87
+ return file_entry(fn) if File.file?(fn)
88
+
89
+ fn = "#{path}.rb"
90
+ return file_entry(fn) if File.file?(fn)
91
+
92
+ fn = "#{path}+.rb"
93
+ return file_entry(fn) if File.file?(fn)
94
+
95
+ NOT_FOUND
96
+ end
97
+
98
+ def find_up_tree_module(path)
99
+ parent = parent_path(path)
100
+ return NOT_FOUND if !parent
101
+
102
+ entry = find_route("#{parent}+.rb", cache: false)
103
+ entry[:kind] == :module ? entry : NOT_FOUND
104
+ end
105
+
106
+ UP_TREE_PATH_RE = /^(.+)?\/[^\/]+$/
107
+
108
+ def parent_path(path)
109
+ m = path.match(UP_TREE_PATH_RE)
110
+ m && m[1]
111
+ end
112
+
113
+ def render_entry(req, entry)
114
+ case entry[:kind]
115
+ when :not_found
116
+ req.respond('Not found', ':status' => Qeweney::Status::NOT_FOUND)
117
+ when :static
118
+ entry[:mime_type] ||= Qeweney::MimeTypes[File.extname(entry[:fn])]
119
+ req.respond(IO.read(entry[:fn]), 'Content-Type' => entry[:mime_type])
120
+ when :markdown
121
+ body = render_markdown(IO.read(entry[:fn]))
122
+ req.respond(body, 'Content-Type' => 'text/html')
123
+ when :module
124
+ call_module(entry, req)
125
+ else
126
+ raise "Invalid entry kind"
127
+ end
128
+ end
129
+
130
+ def call_module(entry, req)
131
+ entry[:code] ||= load_module(entry)
132
+ if entry[:code] == :invalid
133
+ req.respond(nil, ':status' => Qeweney::Status::INTERNAL_SERVER_ERROR)
134
+ return
135
+ end
136
+
137
+ entry[:code].call(req)
138
+ rescue StandardError => e
139
+ p e
140
+ p e.backtrace
141
+ req.respond(nil, ':status' => Qeweney::S*tatus::INTERNAL_SERVER_ERROR)
142
+ end
143
+
144
+ def load_module(entry)
145
+ body = IO.read(entry[:fn])
146
+ klass = Class.new
147
+ o = klass.instance_eval(body, entry[:fn], 1)
148
+
149
+ if o.is_a?(Papercraft::HTML)
150
+ return wrap_template(o)
151
+ else
152
+ return o
153
+ end
154
+ end
155
+
156
+ def wrap_template(templ)
157
+ ->(req) {
158
+ body = templ.render
159
+ req.respond(body, 'Content-Type' => 'text/html')
160
+ }
161
+ end
162
+
163
+ def render_markdown(str)
164
+ Papercraft.markdown(str)
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,58 @@
1
+ # # frozen_string_literal: true
2
+
3
+ # require 'syntropy/errors'
4
+
5
+ # module Syntropy
6
+ # class Context
7
+ # attr_reader :request
8
+
9
+ # def initialize(request)
10
+ # @request = request
11
+ # end
12
+
13
+ # def params
14
+ # @request.query
15
+ # end
16
+
17
+ # def validate_param(name, *clauses)
18
+ # value = @request.query[name]
19
+ # clauses.each do |c|
20
+ # valid = param_is_valid?(value, c)
21
+ # raise(Syntropy::ValidationError, 'Validation error') if !valid
22
+ # value = param_convert(value, c)
23
+ # end
24
+ # value
25
+ # end
26
+
27
+ # BOOL_REGEXP = /^(t|f|true|false|on|off|1|0|yes|no)$/
28
+ # BOOL_TRUE_REGEXP = /^(t|true|on|1|yes)$/
29
+ # INTEGER_REGEXP = /^[\+\-]?[0-9]+$/
30
+ # FLOAT_REGEXP = /^[\+\-]?[0-9]+(\.[0-9]+)?$/
31
+
32
+ # def param_is_valid?(value, cond)
33
+ # if cond == :bool
34
+ # return (value && value =~ BOOL_REGEXP)
35
+ # elsif cond == Integer
36
+ # return (value && value =~ INTEGER_REGEXP)
37
+ # elsif cond == Float
38
+ # return (value && value =~ FLOAT_REGEXP)
39
+ # elsif cond.is_a?(Array)
40
+ # return cond.any? { |c| param_is_valid?(value, c) }
41
+ # end
42
+
43
+ # cond === value
44
+ # end
45
+
46
+ # def param_convert(value, klass)
47
+ # if klass == :bool
48
+ # value = value =~ BOOL_TRUE_REGEXP ? true : false
49
+ # elsif klass == Integer
50
+ # value = value.to_i
51
+ # elsif klass == Float
52
+ # value = value.to_f
53
+ # else
54
+ # value
55
+ # end
56
+ # end
57
+ # end
58
+ # end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'qeweney'
4
+
5
+ module Syntropy
6
+ class Error < StandardError
7
+ attr_reader :http_status
8
+
9
+ def initialize(status, msg = '')
10
+ @http_status = status || Qeweney::Status::INTERNAL_SERVER_ERROR
11
+ super(msg)
12
+ end
13
+ end
14
+
15
+ class ValidationError < Error
16
+ def initialize(msg)
17
+ @http_status = Qeweney::Status::BAD_REQUEST
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'qeweney'
4
+ require 'syntropy/errors'
5
+ require 'json'
6
+
7
+ module Syntropy
8
+ class RPCAPI
9
+ def call(req)
10
+ response, status = invoke(req)
11
+ req.respond(
12
+ response.to_json,
13
+ ':status' => status,
14
+ 'Content-Type' => 'application/json'
15
+ )
16
+ end
17
+
18
+ def invoke(req)
19
+ q = req.validate_param(:q, String)
20
+ response = case req.method
21
+ when 'get'
22
+ send(q.to_sym, req)
23
+ when 'post'
24
+ send(:"#{q}!", req)
25
+ else
26
+ raise Syntropy::Error.new(Qeweney::Status::METHOD_NOT_ALLOWED)
27
+ end
28
+ [{ status: 'OK', response: response }, Qeweney::Status::OK]
29
+ rescue => e
30
+ if !e.is_a?(Syntropy::Error)
31
+ p e
32
+ p e.backtrace
33
+ end
34
+ error_response(e)
35
+ end
36
+
37
+ INTERNAL_SERVER_ERROR = Qeweney::Status::INTERNAL_SERVER_ERROR
38
+
39
+ def error_response(err)
40
+ http_status = err.respond_to?(:http_status) ? err.http_status : INTERNAL_SERVER_ERROR
41
+ error_name = err.class.name
42
+ [{ status: error_name, message: err.message }, http_status]
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syntropy
4
+ VERSION = '0.1'
5
+ end
data/lib/syntropy.rb ADDED
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'qeweney'
4
+
5
+ require 'syntropy/errors'
6
+ # require 'syntropy/context'
7
+ require 'syntropy/rpc_api'
8
+ require 'syntropy/app'
9
+
10
+ class Qeweney::Request
11
+ def ctx
12
+ @ctx ||= {}
13
+ end
14
+
15
+ def validate_param(name, *clauses)
16
+ value = query[name]
17
+ clauses.each do |c|
18
+ valid = param_is_valid?(value, c)
19
+ raise(Syntropy::ValidationError, 'Validation error') if !valid
20
+ value = param_convert(value, c)
21
+ end
22
+ value
23
+ end
24
+
25
+ private
26
+
27
+ BOOL_REGEXP = /^(t|f|true|false|on|off|1|0|yes|no)$/
28
+ BOOL_TRUE_REGEXP = /^(t|true|on|1|yes)$/
29
+ INTEGER_REGEXP = /^[\+\-]?[0-9]+$/
30
+ FLOAT_REGEXP = /^[\+\-]?[0-9]+(\.[0-9]+)?$/
31
+
32
+ def param_is_valid?(value, cond)
33
+ if cond == :bool
34
+ return (value && value =~ BOOL_REGEXP)
35
+ elsif cond == Integer
36
+ return (value && value =~ INTEGER_REGEXP)
37
+ elsif cond == Float
38
+ return (value && value =~ FLOAT_REGEXP)
39
+ elsif cond.is_a?(Array)
40
+ return cond.any? { |c| param_is_valid?(value, c) }
41
+ end
42
+
43
+ cond === value
44
+ end
45
+
46
+ def param_convert(value, klass)
47
+ if klass == :bool
48
+ value = value =~ BOOL_TRUE_REGEXP ? true : false
49
+ elsif klass == Integer
50
+ value = value.to_i
51
+ elsif klass == Float
52
+ value = value.to_f
53
+ else
54
+ value
55
+ end
56
+ end
57
+ end
58
+
59
+ module Syntropy
60
+ end
data/syntropy.gemspec ADDED
@@ -0,0 +1,32 @@
1
+ require_relative './lib/syntropy/version'
2
+
3
+ Gem::Specification.new do |s|
4
+ s.version = Syntropy::VERSION
5
+ s.licenses = ['MIT']
6
+ s.author = 'Sharon Rosner'
7
+ s.email = 'sharon@noteflakes.com'
8
+ s.files = `git ls-files`.split
9
+
10
+ s.homepage = 'https://github.com/noteflakes/syntropy'
11
+ s.metadata = {
12
+ 'homepage_uri' => 'https://github.com/noteflakes/syntropy',
13
+ 'documentation_uri' => 'https://www.rubydoc.info/gems/syntropy',
14
+ 'changelog_uri' => 'https://github.com/noteflakes/syntropy/blob/master/CHANGELOG.md'
15
+ }
16
+ s.rdoc_options = ['--title', 'Extralite', '--main', 'README.md']
17
+ s.extra_rdoc_files = ['README.md']
18
+ s.require_paths = ['lib']
19
+ s.required_ruby_version = '>= 3.2'
20
+
21
+ s.add_dependency 'json', '2.12.2'
22
+ s.add_dependency 'qeweney', '0.21'
23
+ s.add_dependency 'papercraft', '1.4'
24
+ s.add_dependency 'tp2', '0.11.3'
25
+ s.add_dependency 'uringmachine', '0.14'
26
+
27
+ s.add_development_dependency 'minitest', '5.25.5'
28
+ s.add_development_dependency 'rake', '13.3.0'
29
+
30
+ s.name = 'syntropy'
31
+ s.summary = 'Syntropic Web Framework'
32
+ end
@@ -0,0 +1,8 @@
1
+ ->(**props) {
2
+ header {
3
+ h1 'Foo'
4
+ }
5
+ content {
6
+ emit_yield(**props)
7
+ }
8
+ }
@@ -0,0 +1 @@
1
+ Hello from Markdown
@@ -0,0 +1,3 @@
1
+ ->(req) {
2
+ req.respond('About')
3
+ }
data/test/app/api+.rb ADDED
@@ -0,0 +1,19 @@
1
+ class API < Syntropy::RPCAPI
2
+ def initialize
3
+ @count = 0
4
+ end
5
+
6
+ def get(req)
7
+ @count
8
+ end
9
+
10
+ def incr!(req)
11
+ if req.path != '/test/api'
12
+ raise Syntropy::Error.new(Qeweney::Status::TEAPOT, 'Teapot')
13
+ end
14
+
15
+ @count += 1
16
+ end
17
+ end
18
+
19
+ API.new
@@ -0,0 +1 @@
1
+ * { color: beige }
data/test/app/bar.rb ADDED
@@ -0,0 +1,3 @@
1
+ ->(req) {
2
+ req.respond('foobar')
3
+ }
@@ -0,0 +1 @@
1
+ <h1>Hello, world!</h1>
data/test/helper.rb ADDED
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require_relative './coverage' if ENV['COVERAGE']
5
+ require 'uringmachine'
6
+ require 'syntropy'
7
+ require 'qeweney/mock_adapter'
8
+ require 'minitest/autorun'
9
+
10
+ STDOUT.sync = true
11
+ STDERR.sync = true
12
+
13
+ module ::Kernel
14
+ def mock_req(**args)
15
+ Qeweney::MockAdapter.mock(**args)
16
+ end
17
+
18
+ def capture_exception
19
+ yield
20
+ rescue Exception => e
21
+ e
22
+ end
23
+
24
+ def debug(**h)
25
+ k, v = h.first
26
+ h.delete(k)
27
+
28
+ rest = h.inject(+'') { |s, (k, v)| s << " #{k}: #{v.inspect}\n" }
29
+ STDOUT.orig_write("#{k}=>#{v} #{caller[0]}\n#{rest}")
30
+ end
31
+
32
+ def trace(*args)
33
+ STDOUT.orig_write(format_trace(args))
34
+ end
35
+
36
+ def format_trace(args)
37
+ if args.first.is_a?(String)
38
+ if args.size > 1
39
+ format("%s: %p\n", args.shift, args)
40
+ else
41
+ format("%s\n", args.first)
42
+ end
43
+ else
44
+ format("%p\n", args.size == 1 ? args.first : args)
45
+ end
46
+ end
47
+
48
+ def monotonic_clock
49
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
50
+ end
51
+ end
52
+
53
+ module Minitest::Assertions
54
+ def assert_in_range exp_range, act
55
+ msg = message(msg) { "Expected #{mu_pp(act)} to be in range #{mu_pp(exp_range)}" }
56
+ assert exp_range.include?(act), msg
57
+ end
58
+
59
+ def assert_response exp_body, exp_content_type, req
60
+ status = req.response_status
61
+ msg = message(msg) { "Expected HTTP status 200 OK, but instead got #{status}" }
62
+ assert_equal 200, status, msg
63
+
64
+ actual = req.adapter.body
65
+ assert_equal exp_body.gsub("\n", ''), actual&.gsub("\n", '')
66
+
67
+ return unless exp_content_type
68
+
69
+ if Symbol === exp_content_type
70
+ exp_content_type = Qeweney::MimeTypes[exp_content_type]
71
+ end
72
+ actual = req.response_content_type
73
+ assert_equal exp_content_type, actual
74
+ end
75
+ end
76
+
77
+ # Extensions to be used in conjunction with `Qeweney::TestAdapter`
78
+ class Qeweney::Request
79
+ def response_headers
80
+ adapter.headers
81
+ end
82
+
83
+ def response_status
84
+ adapter.status
85
+ end
86
+
87
+ def response_body
88
+ adapter.body
89
+ end
90
+
91
+ def response_json
92
+ raise if response_content_type != 'application/json'
93
+ JSON.parse(response_body, symbolize_names: true)
94
+ end
95
+
96
+ def response_content_type
97
+ response_headers['Content-Type']
98
+ end
99
+ end
100
+
101
+ # puts "Polyphony backend: #{Thread.current.backend.kind}"
data/test/run.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir.glob("#{__dir__}/test_*.rb").each do |path|
4
+ require(path)
5
+ end
data/test/test_app.rb ADDED
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+
5
+ class AppRoutingTest < Minitest::Test
6
+ APP_ROOT = File.join(__dir__, 'app')
7
+
8
+ def setup
9
+ @app = Syntropy::App.new(APP_ROOT, '/test')
10
+ end
11
+
12
+ def full_path(fn)
13
+ File.join(APP_ROOT, fn)
14
+ end
15
+
16
+ def test_find_route
17
+ entry = @app.find_route('/')
18
+ assert_equal :not_found, entry[:kind]
19
+
20
+ entry = @app.find_route('/test')
21
+ assert_equal :static, entry[:kind]
22
+ assert_equal full_path('index.html'), entry[:fn]
23
+
24
+ entry = @app.find_route('/test/about')
25
+ assert_equal :module, entry[:kind]
26
+ assert_equal full_path('about/index.rb'), entry[:fn]
27
+
28
+ entry = @app.find_route('/test/../test_app.rb')
29
+ assert_equal :not_found, entry[:kind]
30
+
31
+ entry = @app.find_route('/test/_layout/default')
32
+ assert_equal :not_found, entry[:kind]
33
+
34
+ entry = @app.find_route('/test/api')
35
+ assert_equal :module, entry[:kind]
36
+ assert_equal full_path('api+.rb'), entry[:fn]
37
+
38
+ entry = @app.find_route('/test/api/foo/bar')
39
+ assert_equal :module, entry[:kind]
40
+ assert_equal full_path('api+.rb'), entry[:fn]
41
+
42
+ entry = @app.find_route('/test/api/foo/../bar')
43
+ assert_equal :not_found, entry[:kind]
44
+
45
+ entry = @app.find_route('/test/api_1')
46
+ assert_equal :not_found, entry[:kind]
47
+
48
+ entry = @app.find_route('/test/about/foo')
49
+ assert_equal :markdown, entry[:kind]
50
+ assert_equal full_path('about/foo.md'), entry[:fn]
51
+ end
52
+
53
+ def make_request(*, **)
54
+ req = mock_req(*, **)
55
+ @app.call(req)
56
+ req
57
+ end
58
+
59
+ def test_app_rendering
60
+ req = make_request(':method' => 'GET', ':path' => '/')
61
+ assert_equal Qeweney::Status::NOT_FOUND, req.response_status
62
+
63
+ req = make_request(':method' => 'GET', ':path' => '/test')
64
+ assert_equal Qeweney::Status::OK, req.response_status
65
+ assert_equal '<h1>Hello, world!</h1>', req.response_body
66
+
67
+ req = make_request(':method' => 'GET', ':path' => '/test/index')
68
+ assert_equal '<h1>Hello, world!</h1>', req.response_body
69
+
70
+ req = make_request(':method' => 'GET', ':path' => '/test/index.html')
71
+ assert_equal '<h1>Hello, world!</h1>', req.response_body
72
+
73
+ req = make_request(':method' => 'GET', ':path' => '/test/assets/style.css')
74
+ assert_equal '* { color: beige }', req.response_body
75
+
76
+ req = make_request(':method' => 'GET', ':path' => '/assets/style.css')
77
+ assert_equal Qeweney::Status::NOT_FOUND, req.response_status
78
+
79
+ req = make_request(':method' => 'GET', ':path' => '/test/api?q=get')
80
+ assert_equal({ status: 'OK', response: 0 }, req.response_json)
81
+
82
+ req = make_request(':method' => 'GET', ':path' => '/test/api/foo?q=get')
83
+ assert_equal({ status: 'OK', response: 0 }, req.response_json)
84
+
85
+ req = make_request(':method' => 'POST', ':path' => '/test/api?q=incr')
86
+ assert_equal({ status: 'OK', response: 1 }, req.response_json)
87
+
88
+ req = make_request(':method' => 'POST', ':path' => '/test/api/foo?q=incr')
89
+ assert_equal({ status: 'Syntropy::Error', message: 'Teapot' }, req.response_json)
90
+ assert_equal Qeweney::Status::TEAPOT, req.response_status
91
+
92
+ req = make_request(':method' => 'GET', ':path' => '/test/bar')
93
+ assert_equal 'foobar', req.response_body
94
+
95
+ req = make_request(':method' => 'GET', ':path' => '/test/about')
96
+ assert_equal 'About', req.response_body.chomp
97
+
98
+ req = make_request(':method' => 'GET', ':path' => '/test/about/foo')
99
+ assert_equal '<p>Hello from Markdown</p>', req.response_body.chomp
100
+
101
+ req = make_request(':method' => 'GET', ':path' => '/test/about/foo/bar')
102
+ assert_equal Qeweney::Status::NOT_FOUND, req.response_status
103
+
104
+
105
+ end
106
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+
5
+ class RPCAPITest < Minitest::Test
6
+ class TestAPI < Syntropy::RPCAPI
7
+ def get(req)
8
+ @value
9
+ end
10
+
11
+ def set!(req)
12
+ @value = req.query[:v]
13
+ true
14
+ end
15
+ end
16
+
17
+ def setup
18
+ @app = TestAPI.new
19
+ end
20
+
21
+ def test_rpc_api
22
+ req = mock_req(':method' => 'GET', ':path' => '/')
23
+ @app.call(req)
24
+ assert_equal Qeweney::Status::BAD_REQUEST, req.response_status
25
+
26
+ req = mock_req(':method' => 'GET', ':path' => '/?q=get')
27
+ @app.call(req)
28
+ assert_equal Qeweney::Status::OK, req.response_status
29
+ assert_equal({ status: 'OK', response: nil }, req.response_json)
30
+
31
+ req = mock_req(':method' => 'POST', ':path' => '/?q=set&v=foo')
32
+ @app.call(req)
33
+ assert_equal Qeweney::Status::OK, req.response_status
34
+ assert_equal({ status: 'OK', response: true }, req.response_json)
35
+
36
+ req = mock_req(':method' => 'GET', ':path' => '/?q=get')
37
+ @app.call(req)
38
+ assert_equal Qeweney::Status::OK, req.response_status
39
+ assert_equal({ status: 'OK', response: 'foo' }, req.response_json)
40
+ end
41
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+
5
+ class ValidationTest < Minitest::Test
6
+ def setup
7
+ @req = mock_req(':method' => 'GET', ':path' => '/foo?q=foo&x=bar&y=123&z1=t&z2=f')
8
+ end
9
+
10
+ VE = Syntropy::ValidationError
11
+
12
+ def test_validate_param
13
+ assert_nil @req.validate_param(:azerty, nil)
14
+ assert_equal 'foo', @req.validate_param(:q)
15
+ assert_equal 'foo', @req.validate_param(:q, String)
16
+ assert_equal 'foo', @req.validate_param(:q, [String, nil])
17
+ assert_nil @req.validate_param(:r, [String, nil])
18
+
19
+ assert_equal 123, @req.validate_param(:y, Integer)
20
+ assert_equal 123, @req.validate_param(:y, Integer, 120..125)
21
+ assert_equal 123.0, @req.validate_param(:y, Float)
22
+
23
+ assert_equal true, @req.validate_param(:z1, :bool)
24
+ assert_equal false, @req.validate_param(:z2, :bool)
25
+
26
+ assert_raises(VE) { @req.validate_param(:azerty, String) }
27
+ assert_raises(VE) { @req.validate_param(:q, Integer) }
28
+ assert_raises(VE) { @req.validate_param(:q, Float) }
29
+ assert_raises(VE) { @req.validate_param(:q, nil) }
30
+
31
+ assert_raises(VE) { @req.validate_param(:y, Integer, 1..100) }
32
+
33
+ assert_raises(VE) { @req.validate_param(:y, :bool) }
34
+ end
35
+ end
metadata ADDED
@@ -0,0 +1,171 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: syntropy
3
+ version: !ruby/object:Gem::Version
4
+ version: '0.1'
5
+ platform: ruby
6
+ authors:
7
+ - Sharon Rosner
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 2.12.2
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 2.12.2
26
+ - !ruby/object:Gem::Dependency
27
+ name: qeweney
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - '='
31
+ - !ruby/object:Gem::Version
32
+ version: '0.21'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - '='
38
+ - !ruby/object:Gem::Version
39
+ version: '0.21'
40
+ - !ruby/object:Gem::Dependency
41
+ name: papercraft
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - '='
45
+ - !ruby/object:Gem::Version
46
+ version: '1.4'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - '='
52
+ - !ruby/object:Gem::Version
53
+ version: '1.4'
54
+ - !ruby/object:Gem::Dependency
55
+ name: tp2
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - '='
59
+ - !ruby/object:Gem::Version
60
+ version: 0.11.3
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - '='
66
+ - !ruby/object:Gem::Version
67
+ version: 0.11.3
68
+ - !ruby/object:Gem::Dependency
69
+ name: uringmachine
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - '='
73
+ - !ruby/object:Gem::Version
74
+ version: '0.14'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - '='
80
+ - !ruby/object:Gem::Version
81
+ version: '0.14'
82
+ - !ruby/object:Gem::Dependency
83
+ name: minitest
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - '='
87
+ - !ruby/object:Gem::Version
88
+ version: 5.25.5
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - '='
94
+ - !ruby/object:Gem::Version
95
+ version: 5.25.5
96
+ - !ruby/object:Gem::Dependency
97
+ name: rake
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - '='
101
+ - !ruby/object:Gem::Version
102
+ version: 13.3.0
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - '='
108
+ - !ruby/object:Gem::Version
109
+ version: 13.3.0
110
+ email: sharon@noteflakes.com
111
+ executables: []
112
+ extensions: []
113
+ extra_rdoc_files:
114
+ - README.md
115
+ files:
116
+ - ".github/workflows/test.yml"
117
+ - ".gitignore"
118
+ - CHANGELOG.md
119
+ - Gemfile
120
+ - LICENSE
121
+ - README.md
122
+ - Rakefile
123
+ - TODO.md
124
+ - lib/syntropy.rb
125
+ - lib/syntropy/app.rb
126
+ - lib/syntropy/context.rb
127
+ - lib/syntropy/errors.rb
128
+ - lib/syntropy/rpc_api.rb
129
+ - lib/syntropy/version.rb
130
+ - syntropy.gemspec
131
+ - test/app/_layout/default.rb
132
+ - test/app/about/foo.md
133
+ - test/app/about/index.rb
134
+ - test/app/api+.rb
135
+ - test/app/assets/style.css
136
+ - test/app/bar.rb
137
+ - test/app/index.html
138
+ - test/helper.rb
139
+ - test/run.rb
140
+ - test/test_app.rb
141
+ - test/test_rpc_api.rb
142
+ - test/test_validation.rb
143
+ homepage: https://github.com/noteflakes/syntropy
144
+ licenses:
145
+ - MIT
146
+ metadata:
147
+ homepage_uri: https://github.com/noteflakes/syntropy
148
+ documentation_uri: https://www.rubydoc.info/gems/syntropy
149
+ changelog_uri: https://github.com/noteflakes/syntropy/blob/master/CHANGELOG.md
150
+ rdoc_options:
151
+ - "--title"
152
+ - Extralite
153
+ - "--main"
154
+ - README.md
155
+ require_paths:
156
+ - lib
157
+ required_ruby_version: !ruby/object:Gem::Requirement
158
+ requirements:
159
+ - - ">="
160
+ - !ruby/object:Gem::Version
161
+ version: '3.2'
162
+ required_rubygems_version: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ requirements: []
168
+ rubygems_version: 3.6.8
169
+ specification_version: 4
170
+ summary: Syntropic Web Framework
171
+ test_files: []