raindown 0.9.1 → 0.11.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: 3288235ed874ddbeae5b31b5ef6707e59864f5cca305bb9bc74fcf74a9c36366
4
- data.tar.gz: c7bfc9dcdec92857c433d4abacbc80d221bb6029617972d362af43163c235c39
3
+ metadata.gz: 1dffe3184c451794454149de18701ac6bd1afc2674b6a41385773d0f052bb2e5
4
+ data.tar.gz: 4e49463244dffc8e9b39873e2137f072f8632daec02c314d928fe09bbb115fd5
5
5
  SHA512:
6
- metadata.gz: 1cb1aaaf0f325d154393656cad4040aa8a2205464e577ce69f23931bd2f2727d28222aba9a6d3a059bd2f7531e59050f0056b413a1a72cb72fe66e5e785b300a
7
- data.tar.gz: f89b1e87119328272a1df69a6c2f9c04159f98db03c749774bac1ae533307eff32bc833b111c41ef569456ed9e0f2ec421afc938744b31102e4f6c8c848fcace
6
+ metadata.gz: 9502a0acb5cca634c884f74b8f2fc3e3a83c3fbe5bc635df7fccb779147718563a0a44679b3b3e6a1cf9da9e691026ff55953fc9a6b0aafad9cc48444eec12c6
7
+ data.tar.gz: dc928865b0da23c1b3dd67d859e94a3e718fe370b3d21d9344060cd5019e82fd5cce029b56d6008632690f9f1cc6f782cd41a8c38ca6d233bd06336f29fb7077
@@ -12,6 +12,7 @@ module Raindown
12
12
  # Each returns true when the file changed, and raises when it can't, for the tool to show.
13
13
  module Actions
14
14
  class NotAnEndpoint < StandardError; end
15
+ class FailedCheck < StandardError; end
15
16
 
16
17
  # Editors that open a file at a line with "path:line" rather than "+line path".
17
18
  PATH_LINE_EDITORS = %w[hx helix subl zed].freeze
@@ -26,17 +27,22 @@ module Raindown
26
27
  Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
27
28
  end
28
29
 
29
- # Runs one endpoint's request and writes its response, replacing any response it had.
30
+ # Runs one endpoint's request and writes its response, replacing any response it had, then raises when the response
31
+ # fails its checks, like status: 200.
30
32
  def execute(path, directive)
31
- raise NotAnEndpoint, "#{directive.name} on line #{directive.line} isn't an endpoint" unless directive.endpoint?
32
- raise NotAnEndpoint, "The endpoint on line #{directive.line} only renders, so put it in comments to run it" unless directive.writes
33
-
33
+ check_runnable(directive)
34
34
  content = Utils.read_markdown(path)
35
- rewritten = EndpointRunner.new(content:, bare: BareDirectives.enabled?(path:, content:), only: directive.line - 1).rewrite
36
- return false if rewritten == content
35
+ runner = EndpointRunner.new(content:, path:, bare: BareDirectives.enabled?(path:, content:), only: directive.line - 1)
36
+ rewritten = runner.rewrite
37
+ File.write(path, rewritten) unless rewritten == content
38
+ raise FailedCheck, runner.failures.join("\n") if runner.failures.any?
37
39
 
38
- File.write(path, rewritten)
39
- true
40
+ rewritten != content
41
+ end
42
+
43
+ def check_runnable(directive)
44
+ raise NotAnEndpoint, "#{directive.name} on line #{directive.line} isn't an endpoint" unless directive.endpoint?
45
+ raise NotAnEndpoint, "The endpoint on line #{directive.line} only renders, so put it in comments to run it" unless directive.writes
40
46
  end
41
47
 
42
48
  # The command that opens the file at a line in the person's editor, like ["vim", "+12", "notes.md"].
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'code_blocks'
4
4
  require_relative 'directives'
5
+ require_relative 'endpoint/schema_block'
5
6
  require_relative 'front_matter'
6
7
 
7
8
  module Raindown
@@ -10,6 +11,8 @@ module Raindown
10
11
  # matter, or with the --bare flag. Nodes like toc: and files: are handled by NodeBlocks, before the rest.
11
12
  # Template directives, like if: and for:, are always in Antlers' delimiters, so they're never bare.
12
13
  #
14
+ # A bare schema: shows in bold, with its fields in a code block below it.
15
+ #
13
16
  # Bare directives have no end tag. A bare endpoint closes itself after its curl snippet and response, so where it
14
17
  # needs more than that, write it in comments with an end tag instead:
15
18
  # <!-- endpoint: -->
@@ -65,6 +68,9 @@ module Raindown
65
68
  # Returns the index of the last line it handled. That's the directive itself, so the lines it holds
66
69
  # are expanded too, and a bare directive inside it closes before it does.
67
70
  def expand_line(lines, index)
71
+ schema = SchemaBlock.at(lines, index, bare: true)
72
+ return expand_schema(lines, index, schema) if schema
73
+
68
74
  match = lines[index].match(LINE)
69
75
  return index unless match
70
76
 
@@ -73,6 +79,14 @@ module Raindown
73
79
  index
74
80
  end
75
81
 
82
+ # Returns the index of the code block's last line.
83
+ def expand_schema(lines, index, schema)
84
+ indent = schema.fields.first.to_s[/\A[ \t]*/]
85
+ block = [Directives.marker(lines[index], shown: true), "\n```\n", *schema.fields.map { it.delete_prefix(indent) }, "```\n"]
86
+ lines[index..schema.last] = block
87
+ index + block.length - 1
88
+ end
89
+
76
90
  # The end tag after the block, then the end tag in bold, as a bare directive's shown.
77
91
  def close_block(lines, index, indent:, keyword:)
78
92
  last = public_send(BLOCK_KEYWORDS[keyword], lines, index + 1) || index
data/lib/raindown/cli.rb CHANGED
@@ -5,7 +5,9 @@ require 'trees'
5
5
  require_relative '../raindown'
6
6
  require_relative '../version'
7
7
  require_relative 'blueprints'
8
+ require_relative 'editing'
8
9
  require_relative 'rewriter'
10
+ require_relative 'server'
9
11
  require_relative 'watcher/file_watcher'
10
12
  require_relative 'watcher/supervisor'
11
13
 
@@ -28,15 +30,22 @@ module Raindown
28
30
  PATH is a file, or a directory (default: current directory) whose projects with a
29
31
  .raindown folder are watched
30
32
  init [DIRECTORY] Create a .raindown folder for the project's config and nodes (default: current directory)
33
+ server Keep Raindown loaded for an editor, answering a JSON request a line on stdin with its HTML,
34
+ or its Markdown as it would be written back, like --stdin and --stdin --rewrite
31
35
  blueprints Save the blueprints in Claude Code's last reply to .raindown/blueprints.md, for the terminal
32
36
  UI's Blueprints page, as a Stop hook that's given the hook's JSON
33
37
 
34
38
  Options:
35
- --html [OUTPUT] Render FILE to HTML once, writing to OUTPUT or else printing to stdout
39
+ --html [OUTPUT] Render FILE to HTML once, writing to OUTPUT, or to stdout without one or with -, then exit with 1
40
+ if its endpoint responses fail their status: or schema: checks
36
41
  --stdin Render Markdown from stdin to HTML once, without formatting FILE, which only needs
37
42
  its extension (to allow bare directives) and can be unsaved. Add --html OUTPUT to write a file
43
+ --rewrite With --stdin, print its Markdown as it would be written back to FILE, without writing
44
+ FILE, for editors like Obsidian. Add -x to run its endpoints
38
45
  --bare Allow bare directives like :toc in Markdown files
39
46
  -x, --execute Run endpoint curl snippets that have no output yet, as files are written back
47
+ --env NAME Fill endpoint snippets from the NAME environment in .raindown/environments.yml and
48
+ environments.local.yml
40
49
  -h, --help Show this message
41
50
  -v, --version Show the version
42
51
 
@@ -52,12 +61,20 @@ module Raindown
52
61
 
53
62
  # A path opens the terminal UI, or after "watch" it's watched, and with --html or --stdin it's rendered. The flags
54
63
  # are keyword arguments, in any order, like -x for x:, and --html is the OUTPUT when it's given one.
55
- line(':path') do |path, html: nil, stdin: false, bare: false, execute: false, x: false| # rubocop:disable Metrics/ParameterLists -- each keyword is a flag
56
- execute { open_path(path, html:, stdin:, bare:, execute: execute || x) }
64
+ line(':path') do |path, html: nil, stdin: false, rewrite: false, bare: false, execute: false, x: false, env: nil| # rubocop:disable Metrics/ParameterLists -- each keyword is a flag
65
+ execute do
66
+ ENV['RAINDOWN_ENV'] = env if env
67
+ next rewrite_stdin(path, bare:, execute: execute || x) if stdin && rewrite
68
+
69
+ open_path(path, html:, stdin:, bare:, execute: execute || x)
70
+ end
57
71
  end
58
72
 
59
- line('watch :path') do |path, bare: false, execute: false, x: false|
60
- execute { watch(path, bare:, execute: execute || x) }
73
+ line('watch :path') do |path, bare: false, execute: false, x: false, env: nil|
74
+ execute do
75
+ ENV['RAINDOWN_ENV'] = env if env
76
+ watch(path, bare:, execute: execute || x)
77
+ end
61
78
  end
62
79
 
63
80
  def self.run(argv)
@@ -80,6 +97,7 @@ module Raindown
80
97
  if argv.intersect?(%w[-v --version]) then puts(VERSION)
81
98
  elsif argv.first == 'init' && argv.size <= 2 then init(argv[1])
82
99
  elsif argv == ['blueprints'] then Blueprints.save(JSON.parse($stdin.read))
100
+ elsif argv == ['server'] then serve
83
101
  else return false
84
102
  end
85
103
 
@@ -101,8 +119,10 @@ module Raindown
101
119
 
102
120
  FileUtils.mkdir_p(folder)
103
121
  File.write(config, CONFIG)
122
+ gitignore = File.join(folder, '.gitignore')
123
+ File.write(gitignore, "#{EndpointVariables::LOCAL_FILE}\n") unless File.exist?(gitignore)
104
124
  puts "Created #{config}"
105
- puts 'Put your own nodes in the same folder. See https://raindown.dev/docs/nodes'
125
+ puts 'Put your own nodes in the same folder. See https://raindown.dev/docs/components'
106
126
  end
107
127
 
108
128
  # The terminal UI loads curses, so it's only required when it starts. Without a terminal, the path's watched instead.
@@ -116,9 +136,9 @@ module Raindown
116
136
  class << self
117
137
  private
118
138
 
119
- # Endpoints run while formatting, which --stdin skips, so -x can't go with it.
139
+ # Endpoints run while formatting, which --stdin skips, so -x can't go with it. --html - prints, like --html.
120
140
  def open_path(path, html:, stdin:, bare:, execute:)
121
- output = html unless html == true
141
+ output = html unless [true, '-'].include?(html)
122
142
  abort("-x runs endpoints as the file's formatted, which --stdin doesn't do\n\n#{USAGE}") if stdin && execute
123
143
  return render_stdin(path, output, bare:) if stdin
124
144
  return render(path, output, bare:, execute:) if html
@@ -134,12 +154,28 @@ module Raindown
134
154
  metadata = Pages.for(path)
135
155
  Rewriter.update(path:, bare:, execute:, metadata:)
136
156
  write(output, Raindown.render_file(path:, bare:, metadata:))
157
+ check_endpoints(path, bare:)
158
+ end
159
+
160
+ # Endpoint responses that fail their checks, like status: 200, are listed, and fail the command, for CI.
161
+ def check_endpoints(path, bare:)
162
+ failures = EndpointRunner.failures(content: Utils.read_markdown(path), path:, bare:)
163
+ return if failures.empty?
164
+
165
+ warn(*failures.map { "Endpoint check failed: #{it}" })
166
+ exit 1
167
+ end
168
+
169
+ # The Markdown from stdin written back as it would be to the file, and its failed endpoint checks, for an editor
170
+ # to apply to what it's editing.
171
+ def rewrite_stdin(path, bare:, execute:)
172
+ rewritten = Editing.rewrite_markdown($stdin.read, path:, bare:, execute:)
173
+ $stdout.write(rewritten[:output])
174
+ warn(*rewritten[:warnings])
137
175
  end
138
176
 
139
177
  def render_stdin(path, output = nil, bare: false, **)
140
- Nodes.load(path)
141
- write(output, Raindown.render(markdown: $stdin.read, metadata: Pages.for(path), bare: bare || BareDirectives.file?(path),
142
- **Config.render_options(path)))
178
+ write(output, Editing.render_markdown($stdin.read, path:, bare:))
143
179
  end
144
180
 
145
181
  # Watching only writes back to the files, while --html and --stdin render HTML.
@@ -151,6 +187,13 @@ module Raindown
151
187
  Watcher::FileWatcher.new(path:, bare:, execute:).start
152
188
  end
153
189
 
190
+ # The answers have stdout to themselves, so what nodes print goes to stderr.
191
+ def serve
192
+ output = $stdout.dup
193
+ $stdout.reopen($stderr)
194
+ Server.new(output:).start
195
+ end
196
+
154
197
  def write(output, html)
155
198
  output ? File.write(output, html) : puts(html)
156
199
  end
@@ -31,16 +31,20 @@ module Raindown
31
31
  # Whether the directive's line is bare, without delimiters, so it shows in bold.
32
32
  def bare?(line) = text(line) == line.strip
33
33
 
34
- # A directive's line as a marker: in bold when it's shown, like a bare directive, or else hidden.
34
+ # A directive's line as a marker: in bold when it's shown, like a bare directive, or else hidden. Its braces are
35
+ # hidden from Antlers like code's, so a shown chart: {stats} shows as it's written.
35
36
  def marker(line, shown: bare?(line))
36
- text = text(line).gsub('--', '- -')
37
+ text = text(line).gsub('--', '- -').gsub(/[{}]/, CODE_BRACES)
37
38
  return "<!-- raindown:hidden #{text} -->" unless shown
38
39
 
39
40
  "<!-- raindown:directive #{text} -->"
40
41
  end
41
42
 
43
+ # The braces' entities, like code's, are kept as they are.
42
44
  def render(html)
43
- html.gsub(HIDDEN, '').gsub(MARKER) { "<p><strong>#{CGI.escapeHTML(::Regexp.last_match(1).to_s)}</strong></p>" }
45
+ html.gsub(HIDDEN, '').gsub(MARKER) do
46
+ "<p><strong>#{CGI.escapeHTML(::Regexp.last_match(1).to_s).gsub(/&amp;(#12[35];)/, '&\1')}</strong></p>"
47
+ end
44
48
  end
45
49
  end
46
50
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../raindown'
4
+ require_relative 'rewriter'
5
+
6
+ module Raindown
7
+ # What an editor asks of Raindown about Markdown it's editing, which needn't be saved: its HTML, or the Markdown as it
8
+ # would be written back to the file, with its failed endpoint checks. "raindown FILE --stdin" and "raindown server"
9
+ # both answer with it.
10
+ module Editing
11
+ module_function
12
+
13
+ def render_markdown(markdown, path:, bare: false)
14
+ Nodes.load(path)
15
+ Raindown.render(markdown:, metadata: Pages.for(path), bare: bare || BareDirectives.file?(path), **Config.render_options(path))
16
+ end
17
+
18
+ def rewrite_markdown(markdown, path:, bare: false, execute: false)
19
+ Nodes.load(path)
20
+ rewritten = Rewriter.rewrite(content: markdown, path:, bare:, execute:, metadata: Pages.for(path))
21
+ { output: rewritten, warnings: EndpointRunner.failures(content: rewritten, path:, bare:).map { "Endpoint check failed: #{it}" } }
22
+ end
23
+ end
24
+ end
@@ -1,18 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
3
+ require 'ipaddr'
4
4
  require 'open3'
5
5
  require 'shellwords'
6
+ require 'socket'
7
+ require 'uri'
8
+ require_relative 'curl_response'
6
9
 
7
10
  module Raindown
8
11
  # Runs a curl snippet with the curl binary. There's no shell, so pipes, redirects and variables don't apply.
9
12
  # Snippets come from files that anyone (or any agent) may have written, so only options that make an HTTP request
10
13
  # are allowed: nothing that reads or writes local files, loads a config, or uses another protocol.
14
+ # Link-local addresses, like the cloud metadata service at 169.254.169.254, are refused, as they can give out
15
+ # credentials, and redirects aren't followed (no -L), so a URL can't redirect there either.
11
16
  module Curl
12
17
  class Error < StandardError; end
13
18
  Request = Data.define(:method, :url)
14
19
 
15
20
  TIMEOUT = '30'
21
+ # The variables curl reads a proxy from, which then looks the names up rather than curl.
22
+ PROXY_VARIABLES = %w[http_proxy https_proxy HTTPS_PROXY all_proxy ALL_PROXY].freeze
16
23
  # --disable must come first and skips ~/.curlrc. The protocol options stop redirects to file:// and the like.
17
24
  SAFE_OPTIONS = ['--disable', '--proto', '=http,https', '--proto-redir', '=http,https'].freeze
18
25
 
@@ -21,7 +28,7 @@ module Raindown
21
28
 
22
29
  # Options without a value.
23
30
  FLAGS = {
24
- '-G' => '--get', '-I' => '--head', '-L' => '--location', '-i' => '--include', '-k' => '--insecure',
31
+ '-G' => '--get', '-I' => '--head', '-i' => '--include', '-k' => '--insecure',
25
32
  '-f' => '--fail', '-g' => '--globoff', '-s' => '--silent', '-S' => '--show-error'
26
33
  }.freeze
27
34
  LONG_FLAGS = (FLAGS.values + %w[--compressed --http1.1 --http2]).freeze
@@ -41,33 +48,24 @@ module Raindown
41
48
  '--data-urlencode' => :no_urlencoded_file
42
49
  }.freeze
43
50
 
44
- # Separates the response body from the content type that --write-out appends.
45
- TYPE_MARKER = "\n--raindown-content-type--"
46
- WRITE_OUT = "#{TYPE_MARKER}%{content_type}".freeze # rubocop:disable Style/FormatStringToken -- curl's syntax, not Ruby's
47
- LANGUAGES = {
48
- /json/ => 'json', /xml/ => 'xml', /html/ => 'html', /javascript/ => 'javascript',
49
- /css/ => 'css', /yaml/ => 'yaml', %r{\Atext/plain} => 'text'
50
- }.freeze
51
-
52
51
  module_function
53
52
 
54
- # Returns the response body and the code block language for its content type.
55
- def run(snippet)
53
+ # Returns the Response, with the code block language for its content type.
54
+ # Placeholders like {base_url} are replaced from the variables, word by word, so a value can't add options.
55
+ def run(snippet, variables = nil)
56
+ args = arguments(snippet, variables)
56
57
  stdout, stderr, status = Open3.capture3('curl', *SAFE_OPTIONS, '--silent', '--show-error', '--max-time', TIMEOUT,
57
- *arguments(snippet), '--write-out', WRITE_OUT)
58
+ *resolve_options(args), *args, '--write-out', Response::WRITE_OUT)
58
59
  raise Error, stderr.strip.empty? ? "curl exited with #{status.exitstatus}" : stderr.strip unless status.success?
59
60
 
60
- body, _, content_type = stdout.rpartition(TYPE_MARKER)
61
- language = language(content_type)
62
-
63
- [language == 'json' ? pretty_json(body) : body, language]
61
+ Response.parse(stdout)
64
62
  rescue Errno::ENOENT
65
63
  raise Error, 'curl is not installed'
66
64
  end
67
65
 
68
66
  # The snippet's method and URL, like GET https://httpbin.org/uuid, or nil when it can't be read.
69
- def request(snippet)
70
- args = arguments(snippet)
67
+ def request(snippet, variables = nil)
68
+ args = arguments(snippet, variables)
71
69
  Request.new(method: request_method(args), url: args.find { it.match?(URL) })
72
70
  rescue Error
73
71
  nil
@@ -83,8 +81,9 @@ module Raindown
83
81
  'GET'
84
82
  end
85
83
 
86
- def arguments(snippet)
84
+ def arguments(snippet, variables = nil)
87
85
  words = Shellwords.split(snippet.gsub(/\\\r?\n/, ' ').strip.delete_prefix('$ '))
86
+ words = words.map { variables.substitute(it) } if variables
88
87
  raise Error, 'Endpoint snippets must start with curl' unless words.first == 'curl'
89
88
  raise Error, 'Endpoint snippets must be a single curl command' if words.intersect?(OPERATORS)
90
89
 
@@ -120,7 +119,7 @@ module Raindown
120
119
  [option, value(option, words.shift)]
121
120
  end
122
121
 
123
- # Short flags can be grouped like -sSL, and a short option's value can be attached like -XPOST.
122
+ # Short flags can be grouped like -sS, and a short option's value can be attached like -XPOST.
124
123
  def short_options(word, words)
125
124
  letters = word.delete_prefix('-')
126
125
  args = []
@@ -160,14 +159,48 @@ module Raindown
160
159
  value
161
160
  end
162
161
 
163
- def language(content_type)
164
- LANGUAGES.find { |pattern, _| content_type.match?(pattern) }&.last || ''
162
+ # Resolves each URL's host and pins curl to the address it was checked at, like --resolve api.io:443:203.0.113.5,
163
+ # so the name can't point somewhere else by the time curl looks it up. A host that's already an IP isn't pinned,
164
+ # and behind a proxy, which looks names up itself, only a host that's an IP is checked.
165
+ def resolve_options(args)
166
+ args.grep(URL).flat_map { pin_option(URI.parse(it)) }
167
+ rescue URI::InvalidURIError => e
168
+ raise Error, "Can't read the URL: #{e.message}"
169
+ end
170
+
171
+ def pin_option(uri)
172
+ raise Error, "#{uri} has no host" if uri.hostname.to_s.empty?
173
+ return [] if proxy? && !ip?(uri.hostname)
174
+
175
+ address = allowed_address(uri.hostname)
176
+ return [] if ip?(uri.hostname)
177
+
178
+ pinned = address.to_s
179
+ pinned = "[#{pinned}]" if address.ipv6?
180
+ ['--resolve', "#{uri.hostname}:#{uri.port}:#{pinned}"]
181
+ end
182
+
183
+ # Any way of writing an IP, like 169.254.169.254 or 2852039166, found without a DNS lookup.
184
+ def ip?(host)
185
+ Addrinfo.getaddrinfo(host, nil, nil, :STREAM, nil, Socket::AI_NUMERICHOST)
186
+ true
187
+ rescue SocketError
188
+ false
165
189
  end
166
190
 
167
- def pretty_json(body)
168
- JSON.pretty_generate(JSON.parse(body))
169
- rescue JSON::ParserError
170
- body
191
+ def proxy?
192
+ PROXY_VARIABLES.any? { !ENV[it].to_s.empty? }
193
+ end
194
+
195
+ # The host's first address, unless any of them is link-local.
196
+ def allowed_address(host)
197
+ addresses = Addrinfo.getaddrinfo(host, nil, nil, :STREAM).map { IPAddr.new(it.ip_address.sub(/%.*/, '')).native }
198
+ blocked = addresses.find(&:link_local?)
199
+ raise Error, "Endpoint snippets can't request link-local addresses, like cloud metadata at #{blocked}" if blocked
200
+
201
+ addresses.first
202
+ rescue SocketError => e
203
+ raise Error, "Can't resolve #{host}: #{e.message}"
171
204
  end
172
205
  end
173
206
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Raindown
6
+ module Curl
7
+ # A response's body, its code block language, and its status code and time, like 200 in 142 milliseconds,
8
+ # read from what curl prints with --write-out WRITE_OUT.
9
+ class Response < Data.define(:body, :language, :status, :milliseconds)
10
+ # Separates the response body from the status, time and content type that --write-out appends.
11
+ INFO_MARKER = "\n--raindown-response-info--"
12
+ WRITE_OUT = "#{INFO_MARKER}%{http_code} %{time_total} %{content_type}".freeze # rubocop:disable Style/FormatStringToken -- curl's syntax, not Ruby's
13
+ LANGUAGES = {
14
+ /json/ => 'json', /xml/ => 'xml', /html/ => 'html', /javascript/ => 'javascript',
15
+ /css/ => 'css', /yaml/ => 'yaml', %r{\Atext/plain} => 'text'
16
+ }.freeze
17
+
18
+ # JSON is pretty-printed, and other bodies are kept as they are.
19
+ def self.parse(stdout)
20
+ body, _, info = stdout.rpartition(INFO_MARKER)
21
+ status, seconds, content_type = info.split(' ', 3)
22
+ language = LANGUAGES.find { |pattern, _| content_type.to_s.match?(pattern) }&.last || ''
23
+
24
+ new(body: language == 'json' ? pretty_json(body) : body, language:, status: status.to_i,
25
+ milliseconds: (seconds.to_f * 1000).round)
26
+ end
27
+
28
+ def self.pretty_json(body)
29
+ JSON.pretty_generate(JSON.parse(body))
30
+ rescue JSON::ParserError
31
+ body
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'schema_block'
5
+
6
+ module Raindown
7
+ # An endpoint directive's arguments: as: names its response for later snippets, and status: and schema: are what its
8
+ # response is checked against, like "as: user status: 200 schema: Schema[id: Integer, email: String | nil]".
9
+ # A schema is a LowType type, read without running it, and is written with Schema[] rather than {}, which Antlers reads,
10
+ # or names a SchemaBlock, like schema: 'User' or schema: Array[User].
11
+ class EndpointArgs < Data.define(:name, :status, :schema)
12
+ KEYWORDS = %w[as: status: schema:].freeze
13
+
14
+ def self.parse(text, schemas: {})
15
+ values = keyword_values(text.to_s)
16
+ new(name: values['as'], status: values['status']&.then { Integer(it, exception: false) },
17
+ schema: values['schema']&.then { SchemaBlock.resolve(it, schemas) })
18
+ end
19
+
20
+ # Each keyword's words, up to the next keyword outside brackets, so a schema's own keys, like Schema[status: Integer],
21
+ # stay in it.
22
+ def self.keyword_values(text)
23
+ values = {}
24
+ key = nil
25
+ depth = 0
26
+
27
+ text.split.each do |word|
28
+ next values[key = word.delete_suffix(':')] = [] if depth.zero? && KEYWORDS.include?(word)
29
+ next unless key
30
+
31
+ values[key] << word
32
+ depth += word.count('[(') - word.count('])')
33
+ end
34
+
35
+ values.transform_values { it.join(' ') }
36
+ end
37
+
38
+ # What's wrong with a response, like "expected status 200, received 404" or "id: expected Integer, received "1"".
39
+ # A response fetched before statuses were kept has no status to check.
40
+ def failures(body:, status:)
41
+ failures = []
42
+ failures << "expected status #{self.status}, received #{status}" if self.status && status && status != self.status
43
+ failures.concat(schema_failures(body)) if schema
44
+ failures
45
+ end
46
+
47
+ private
48
+
49
+ # LowType is only loaded for an endpoint with a schema.
50
+ def schema_failures(body)
51
+ require 'lowtype'
52
+
53
+ Low::TypeParser.parse(schema).errors(data(body)).map do |node|
54
+ "#{node.path.to_s.empty? ? 'response' : node.path}: expected #{node.expected}, received #{node.received}"
55
+ end
56
+ rescue Low::ParseError => e
57
+ ["schema: #{e.message}"]
58
+ end
59
+
60
+ def data(body)
61
+ JSON.parse(body)
62
+ rescue JSON::ParserError
63
+ body.strip
64
+ end
65
+ end
66
+ end