reqcord 0.1.0 → 0.1.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.
Files changed (249) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +8 -0
  3. data/CHANGELOG.md +77 -0
  4. data/Gemfile +5 -0
  5. data/Gemfile.lock +171 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +239 -95
  8. data/Rakefile +13 -0
  9. data/docs/configuration.md +319 -0
  10. data/examples/complex-spec-app/.rspec +1 -0
  11. data/examples/complex-spec-app/README.md +21 -0
  12. data/examples/complex-spec-app/app.rb +363 -0
  13. data/examples/complex-spec-app/docs/api/README.md +104 -0
  14. data/examples/complex-spec-app/docs/api/api/v1/admin/products/create.md +117 -0
  15. data/examples/complex-spec-app/docs/api/api/v1/admin/products/destroy.md +51 -0
  16. data/examples/complex-spec-app/docs/api/api/v1/admin/products/index.md +11 -0
  17. data/examples/complex-spec-app/docs/api/api/v1/admin/products/list.md +106 -0
  18. data/examples/complex-spec-app/docs/api/api/v1/auth/index.md +9 -0
  19. data/examples/complex-spec-app/docs/api/api/v1/auth/login.md +76 -0
  20. data/examples/complex-spec-app/docs/api/api/v1/cart-items/create.md +91 -0
  21. data/examples/complex-spec-app/docs/api/api/v1/cart-items/destroy.md +51 -0
  22. data/examples/complex-spec-app/docs/api/api/v1/cart-items/index.md +10 -0
  23. data/examples/complex-spec-app/docs/api/api/v1/carts/checkout.md +102 -0
  24. data/examples/complex-spec-app/docs/api/api/v1/carts/index.md +10 -0
  25. data/examples/complex-spec-app/docs/api/api/v1/carts/show.md +73 -0
  26. data/examples/complex-spec-app/docs/api/api/v1/home/index.md +9 -0
  27. data/examples/complex-spec-app/docs/api/api/v1/home/list.md +47 -0
  28. data/examples/complex-spec-app/docs/api/api/v1/notes/create.md +94 -0
  29. data/examples/complex-spec-app/docs/api/api/v1/notes/index.md +10 -0
  30. data/examples/complex-spec-app/docs/api/api/v1/notes/list.md +61 -0
  31. data/examples/complex-spec-app/docs/api/api/v1/orders/cancel.md +69 -0
  32. data/examples/complex-spec-app/docs/api/api/v1/orders/create.md +150 -0
  33. data/examples/complex-spec-app/docs/api/api/v1/orders/destroy.md +37 -0
  34. data/examples/complex-spec-app/docs/api/api/v1/orders/index.md +13 -0
  35. data/examples/complex-spec-app/docs/api/api/v1/orders/list.md +78 -0
  36. data/examples/complex-spec-app/docs/api/api/v1/orders/show.md +83 -0
  37. data/examples/complex-spec-app/docs/api/api/v1/products/index.md +11 -0
  38. data/examples/complex-spec-app/docs/api/api/v1/products/list.md +83 -0
  39. data/examples/complex-spec-app/docs/api/api/v1/products/search.md +88 -0
  40. data/examples/complex-spec-app/docs/api/api/v1/products/show.md +71 -0
  41. data/examples/complex-spec-app/docs/api/api/v1/profiles/index.md +10 -0
  42. data/examples/complex-spec-app/docs/api/api/v1/profiles/show.md +61 -0
  43. data/examples/complex-spec-app/docs/api/api/v1/profiles/update.md +91 -0
  44. data/examples/complex-spec-app/docs/api/api/v2/products/index.md +9 -0
  45. data/examples/complex-spec-app/docs/api/api/v2/products/list.md +77 -0
  46. data/examples/complex-spec-app/docs/api/curl/api/v1/admin/products/create.sh +18 -0
  47. data/examples/complex-spec-app/docs/api/curl/api/v1/admin/products/destroy.sh +9 -0
  48. data/examples/complex-spec-app/docs/api/curl/api/v1/admin/products/list.sh +9 -0
  49. data/examples/complex-spec-app/docs/api/curl/api/v1/auth/login.sh +9 -0
  50. data/examples/complex-spec-app/docs/api/curl/api/v1/cart-items/create.sh +16 -0
  51. data/examples/complex-spec-app/docs/api/curl/api/v1/cart-items/destroy.sh +9 -0
  52. data/examples/complex-spec-app/docs/api/curl/api/v1/carts/checkout.sh +13 -0
  53. data/examples/complex-spec-app/docs/api/curl/api/v1/carts/show.sh +9 -0
  54. data/examples/complex-spec-app/docs/api/curl/api/v1/home/list.sh +8 -0
  55. data/examples/complex-spec-app/docs/api/curl/api/v1/notes/create.sh +15 -0
  56. data/examples/complex-spec-app/docs/api/curl/api/v1/notes/list.sh +9 -0
  57. data/examples/complex-spec-app/docs/api/curl/api/v1/orders/cancel.sh +9 -0
  58. data/examples/complex-spec-app/docs/api/curl/api/v1/orders/create.sh +29 -0
  59. data/examples/complex-spec-app/docs/api/curl/api/v1/orders/destroy.sh +9 -0
  60. data/examples/complex-spec-app/docs/api/curl/api/v1/orders/list.sh +9 -0
  61. data/examples/complex-spec-app/docs/api/curl/api/v1/orders/show.sh +9 -0
  62. data/examples/complex-spec-app/docs/api/curl/api/v1/products/list.sh +8 -0
  63. data/examples/complex-spec-app/docs/api/curl/api/v1/products/search.sh +8 -0
  64. data/examples/complex-spec-app/docs/api/curl/api/v1/products/show.sh +8 -0
  65. data/examples/complex-spec-app/docs/api/curl/api/v1/profiles/show.sh +9 -0
  66. data/examples/complex-spec-app/docs/api/curl/api/v1/profiles/update.sh +16 -0
  67. data/examples/complex-spec-app/docs/api/curl/api/v2/products/list.sh +8 -0
  68. data/examples/complex-spec-app/docs/api/dataset.json +5116 -0
  69. data/examples/complex-spec-app/docs/api/postman/collection.json +2519 -0
  70. data/examples/complex-spec-app/generate.rb +14 -0
  71. data/examples/complex-spec-app/reqcord.yml +28 -0
  72. data/examples/complex-spec-app/spec/requests/admin_spec.rb +67 -0
  73. data/examples/complex-spec-app/spec/requests/cart_spec.rb +61 -0
  74. data/examples/complex-spec-app/spec/requests/home_and_auth_spec.rb +49 -0
  75. data/examples/complex-spec-app/spec/requests/orders_spec.rb +124 -0
  76. data/examples/complex-spec-app/spec/requests/products_spec.rb +65 -0
  77. data/examples/complex-spec-app/spec/spec_helper.rb +17 -0
  78. data/examples/complex-test-app/README.md +40 -0
  79. data/examples/complex-test-app/app.rb +384 -0
  80. data/examples/complex-test-app/docs/api/README.md +104 -0
  81. data/examples/complex-test-app/docs/api/api/v1/admin/products/create.md +117 -0
  82. data/examples/complex-test-app/docs/api/api/v1/admin/products/destroy.md +51 -0
  83. data/examples/complex-test-app/docs/api/api/v1/admin/products/index.md +11 -0
  84. data/examples/complex-test-app/docs/api/api/v1/admin/products/list.md +106 -0
  85. data/examples/complex-test-app/docs/api/api/v1/auth/index.md +9 -0
  86. data/examples/complex-test-app/docs/api/api/v1/auth/login.md +76 -0
  87. data/examples/complex-test-app/docs/api/api/v1/cart-items/create.md +91 -0
  88. data/examples/complex-test-app/docs/api/api/v1/cart-items/destroy.md +51 -0
  89. data/examples/complex-test-app/docs/api/api/v1/cart-items/index.md +10 -0
  90. data/examples/complex-test-app/docs/api/api/v1/carts/checkout.md +102 -0
  91. data/examples/complex-test-app/docs/api/api/v1/carts/index.md +10 -0
  92. data/examples/complex-test-app/docs/api/api/v1/carts/show.md +73 -0
  93. data/examples/complex-test-app/docs/api/api/v1/home/index.md +9 -0
  94. data/examples/complex-test-app/docs/api/api/v1/home/list.md +47 -0
  95. data/examples/complex-test-app/docs/api/api/v1/notes/create.md +94 -0
  96. data/examples/complex-test-app/docs/api/api/v1/notes/index.md +10 -0
  97. data/examples/complex-test-app/docs/api/api/v1/notes/list.md +61 -0
  98. data/examples/complex-test-app/docs/api/api/v1/orders/cancel.md +69 -0
  99. data/examples/complex-test-app/docs/api/api/v1/orders/create.md +150 -0
  100. data/examples/complex-test-app/docs/api/api/v1/orders/destroy.md +37 -0
  101. data/examples/complex-test-app/docs/api/api/v1/orders/index.md +13 -0
  102. data/examples/complex-test-app/docs/api/api/v1/orders/list.md +71 -0
  103. data/examples/complex-test-app/docs/api/api/v1/orders/show.md +83 -0
  104. data/examples/complex-test-app/docs/api/api/v1/products/index.md +11 -0
  105. data/examples/complex-test-app/docs/api/api/v1/products/list.md +83 -0
  106. data/examples/complex-test-app/docs/api/api/v1/products/search.md +88 -0
  107. data/examples/complex-test-app/docs/api/api/v1/products/show.md +71 -0
  108. data/examples/complex-test-app/docs/api/api/v1/profiles/index.md +10 -0
  109. data/examples/complex-test-app/docs/api/api/v1/profiles/show.md +61 -0
  110. data/examples/complex-test-app/docs/api/api/v1/profiles/update.md +91 -0
  111. data/examples/complex-test-app/docs/api/api/v2/products/index.md +9 -0
  112. data/examples/complex-test-app/docs/api/api/v2/products/list.md +77 -0
  113. data/examples/complex-test-app/docs/api/curl/api/v1/admin/products/create.sh +18 -0
  114. data/examples/complex-test-app/docs/api/curl/api/v1/admin/products/destroy.sh +9 -0
  115. data/examples/complex-test-app/docs/api/curl/api/v1/admin/products/list.sh +9 -0
  116. data/examples/complex-test-app/docs/api/curl/api/v1/auth/login.sh +9 -0
  117. data/examples/complex-test-app/docs/api/curl/api/v1/cart-items/create.sh +16 -0
  118. data/examples/complex-test-app/docs/api/curl/api/v1/cart-items/destroy.sh +9 -0
  119. data/examples/complex-test-app/docs/api/curl/api/v1/carts/checkout.sh +13 -0
  120. data/examples/complex-test-app/docs/api/curl/api/v1/carts/show.sh +9 -0
  121. data/examples/complex-test-app/docs/api/curl/api/v1/home/list.sh +8 -0
  122. data/examples/complex-test-app/docs/api/curl/api/v1/notes/create.sh +15 -0
  123. data/examples/complex-test-app/docs/api/curl/api/v1/notes/list.sh +9 -0
  124. data/examples/complex-test-app/docs/api/curl/api/v1/orders/cancel.sh +9 -0
  125. data/examples/complex-test-app/docs/api/curl/api/v1/orders/create.sh +29 -0
  126. data/examples/complex-test-app/docs/api/curl/api/v1/orders/destroy.sh +9 -0
  127. data/examples/complex-test-app/docs/api/curl/api/v1/orders/list.sh +9 -0
  128. data/examples/complex-test-app/docs/api/curl/api/v1/orders/show.sh +9 -0
  129. data/examples/complex-test-app/docs/api/curl/api/v1/products/list.sh +8 -0
  130. data/examples/complex-test-app/docs/api/curl/api/v1/products/search.sh +8 -0
  131. data/examples/complex-test-app/docs/api/curl/api/v1/products/show.sh +8 -0
  132. data/examples/complex-test-app/docs/api/curl/api/v1/profiles/show.sh +9 -0
  133. data/examples/complex-test-app/docs/api/curl/api/v1/profiles/update.sh +16 -0
  134. data/examples/complex-test-app/docs/api/curl/api/v2/products/list.sh +8 -0
  135. data/examples/complex-test-app/docs/api/dataset.json +5109 -0
  136. data/examples/complex-test-app/docs/api/postman/collection.json +2568 -0
  137. data/examples/complex-test-app/generate.rb +14 -0
  138. data/examples/complex-test-app/reqcord.yml +28 -0
  139. data/examples/complex-test-app/test/integration/admin_test.rb +70 -0
  140. data/examples/complex-test-app/test/integration/cart_test.rb +64 -0
  141. data/examples/complex-test-app/test/integration/home_and_auth_test.rb +53 -0
  142. data/examples/complex-test-app/test/integration/orders_test.rb +127 -0
  143. data/examples/complex-test-app/test/integration/products_test.rb +66 -0
  144. data/examples/reqcord.yml +58 -0
  145. data/examples/spec-app/.rspec +1 -0
  146. data/examples/spec-app/README.md +36 -0
  147. data/examples/spec-app/app.rb +209 -0
  148. data/examples/spec-app/docs/api/README.md +45 -0
  149. data/examples/spec-app/docs/api/api/v1/customers/create.md +95 -0
  150. data/examples/spec-app/docs/api/api/v1/customers/index.md +11 -0
  151. data/examples/spec-app/docs/api/api/v1/customers/list.md +67 -0
  152. data/examples/spec-app/docs/api/api/v1/customers/show.md +65 -0
  153. data/examples/spec-app/docs/api/api/v1/tasks/complete.md +51 -0
  154. data/examples/spec-app/docs/api/api/v1/tasks/create.md +90 -0
  155. data/examples/spec-app/docs/api/api/v1/tasks/destroy.md +35 -0
  156. data/examples/spec-app/docs/api/api/v1/tasks/index.md +14 -0
  157. data/examples/spec-app/docs/api/api/v1/tasks/list.md +65 -0
  158. data/examples/spec-app/docs/api/api/v1/tasks/show.md +65 -0
  159. data/examples/spec-app/docs/api/api/v1/tasks/update.md +74 -0
  160. data/examples/spec-app/docs/api/api/v1/users/create.md +95 -0
  161. data/examples/spec-app/docs/api/api/v1/users/index.md +11 -0
  162. data/examples/spec-app/docs/api/api/v1/users/list.md +67 -0
  163. data/examples/spec-app/docs/api/api/v1/users/show.md +65 -0
  164. data/examples/spec-app/docs/api/curl/api/v1/customers/create.sh +17 -0
  165. data/examples/spec-app/docs/api/curl/api/v1/customers/list.sh +9 -0
  166. data/examples/spec-app/docs/api/curl/api/v1/customers/show.sh +9 -0
  167. data/examples/spec-app/docs/api/curl/api/v1/tasks/complete.sh +8 -0
  168. data/examples/spec-app/docs/api/curl/api/v1/tasks/create.sh +15 -0
  169. data/examples/spec-app/docs/api/curl/api/v1/tasks/destroy.sh +8 -0
  170. data/examples/spec-app/docs/api/curl/api/v1/tasks/list.sh +8 -0
  171. data/examples/spec-app/docs/api/curl/api/v1/tasks/show.sh +8 -0
  172. data/examples/spec-app/docs/api/curl/api/v1/tasks/update.sh +14 -0
  173. data/examples/spec-app/docs/api/curl/api/v1/users/create.sh +17 -0
  174. data/examples/spec-app/docs/api/curl/api/v1/users/list.sh +9 -0
  175. data/examples/spec-app/docs/api/curl/api/v1/users/show.sh +9 -0
  176. data/examples/spec-app/docs/api/dataset.json +2402 -0
  177. data/examples/spec-app/docs/api/postman/collection.json +1290 -0
  178. data/examples/spec-app/generate.rb +14 -0
  179. data/examples/spec-app/reqcord.yml +27 -0
  180. data/examples/spec-app/spec/requests/customers_spec.rb +71 -0
  181. data/examples/spec-app/spec/requests/tasks_spec.rb +78 -0
  182. data/examples/spec-app/spec/requests/users_spec.rb +68 -0
  183. data/examples/spec-app/spec/spec_helper.rb +17 -0
  184. data/examples/test-app/README.md +57 -0
  185. data/examples/test-app/app.rb +212 -0
  186. data/examples/test-app/docs/api/README.md +45 -0
  187. data/examples/test-app/docs/api/api/v1/customers/create.md +95 -0
  188. data/examples/test-app/docs/api/api/v1/customers/index.md +11 -0
  189. data/examples/test-app/docs/api/api/v1/customers/list.md +67 -0
  190. data/examples/test-app/docs/api/api/v1/customers/show.md +65 -0
  191. data/examples/test-app/docs/api/api/v1/tasks/complete.md +51 -0
  192. data/examples/test-app/docs/api/api/v1/tasks/create.md +90 -0
  193. data/examples/test-app/docs/api/api/v1/tasks/destroy.md +35 -0
  194. data/examples/test-app/docs/api/api/v1/tasks/index.md +14 -0
  195. data/examples/test-app/docs/api/api/v1/tasks/list.md +65 -0
  196. data/examples/test-app/docs/api/api/v1/tasks/show.md +65 -0
  197. data/examples/test-app/docs/api/api/v1/tasks/update.md +74 -0
  198. data/examples/test-app/docs/api/api/v1/users/create.md +95 -0
  199. data/examples/test-app/docs/api/api/v1/users/index.md +11 -0
  200. data/examples/test-app/docs/api/api/v1/users/list.md +67 -0
  201. data/examples/test-app/docs/api/api/v1/users/show.md +65 -0
  202. data/examples/test-app/docs/api/curl/api/v1/customers/create.sh +17 -0
  203. data/examples/test-app/docs/api/curl/api/v1/customers/list.sh +9 -0
  204. data/examples/test-app/docs/api/curl/api/v1/customers/show.sh +9 -0
  205. data/examples/test-app/docs/api/curl/api/v1/tasks/complete.sh +8 -0
  206. data/examples/test-app/docs/api/curl/api/v1/tasks/create.sh +15 -0
  207. data/examples/test-app/docs/api/curl/api/v1/tasks/destroy.sh +8 -0
  208. data/examples/test-app/docs/api/curl/api/v1/tasks/list.sh +8 -0
  209. data/examples/test-app/docs/api/curl/api/v1/tasks/show.sh +8 -0
  210. data/examples/test-app/docs/api/curl/api/v1/tasks/update.sh +14 -0
  211. data/examples/test-app/docs/api/curl/api/v1/users/create.sh +17 -0
  212. data/examples/test-app/docs/api/curl/api/v1/users/list.sh +9 -0
  213. data/examples/test-app/docs/api/curl/api/v1/users/show.sh +9 -0
  214. data/examples/test-app/docs/api/dataset.json +2402 -0
  215. data/examples/test-app/docs/api/postman/collection.json +1290 -0
  216. data/examples/test-app/generate.rb +14 -0
  217. data/examples/test-app/reqcord.yml +28 -0
  218. data/examples/test-app/test/integration/customers_test.rb +72 -0
  219. data/examples/test-app/test/integration/tasks_test.rb +80 -0
  220. data/examples/test-app/test/integration/users_test.rb +72 -0
  221. data/lib/reqcord/capture/collector.rb +31 -0
  222. data/lib/reqcord/capture/integration_patch.rb +209 -0
  223. data/lib/reqcord/capture/minitest_context.rb +34 -0
  224. data/lib/reqcord/capture/rspec_context.rb +42 -0
  225. data/lib/reqcord/capture/test_context.rb +25 -0
  226. data/lib/reqcord/capture.rb +19 -0
  227. data/lib/reqcord/configuration.rb +198 -0
  228. data/lib/reqcord/dataset.rb +176 -0
  229. data/lib/reqcord/endpoint.rb +263 -0
  230. data/lib/reqcord/errors.rb +9 -0
  231. data/lib/reqcord/exporters/curl.rb +68 -0
  232. data/lib/reqcord/exporters/markdown.rb +295 -0
  233. data/lib/reqcord/exporters/postman.rb +206 -0
  234. data/lib/reqcord/exporters.rb +32 -0
  235. data/lib/reqcord/generator.rb +364 -0
  236. data/lib/reqcord/railtie.rb +51 -0
  237. data/lib/reqcord/renderers/curl.rb +56 -0
  238. data/lib/reqcord/renderers/payload.rb +69 -0
  239. data/lib/reqcord/request_example.rb +104 -0
  240. data/lib/reqcord/response_example.rb +72 -0
  241. data/lib/reqcord/route_collector.rb +242 -0
  242. data/lib/reqcord/sanitizers/sanitizer.rb +140 -0
  243. data/lib/reqcord/schema.rb +187 -0
  244. data/lib/reqcord/support.rb +58 -0
  245. data/lib/reqcord/version.rb +5 -0
  246. data/lib/reqcord.rb +78 -0
  247. data/lib/tasks/reqcord.rake +99 -0
  248. data/reqcord.gemspec +46 -0
  249. metadata +317 -3
@@ -0,0 +1,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+ require "securerandom"
6
+
7
+ module Reqcord
8
+ # Runs the test suite in a subprocess with capture enabled, then turns the
9
+ # captured exchanges into documentation. Route collection happens here, in a
10
+ # process that already has the application booted.
11
+ class Generator
12
+ def self.call(
13
+ resources: [],
14
+ version: nil
15
+ )
16
+ new(
17
+ resources: resources,
18
+ version: version
19
+ ).call
20
+ end
21
+
22
+ def initialize(resources:, version:, configuration: Reqcord.configuration)
23
+ @resources = Array(resources).map(&:to_s)
24
+ @version = version&.to_s
25
+ @configuration = configuration
26
+ end
27
+
28
+ def call
29
+ validate!
30
+
31
+ routes = collect_routes
32
+
33
+ capture_file = build_capture_file
34
+
35
+ run_tests(capture_file)
36
+
37
+ exchanges =
38
+ read_exchanges(capture_file)
39
+
40
+ dataset =
41
+ build_dataset(
42
+ routes,
43
+ exchanges
44
+ )
45
+
46
+ report(exchanges, dataset)
47
+
48
+ write_outputs(dataset)
49
+
50
+ dataset
51
+ ensure
52
+ FileUtils.rm_f(capture_file) if capture_file
53
+ end
54
+
55
+ # Paths that were captured but belong to no documented route. Kept so the
56
+ # run can say why a request did not turn into documentation.
57
+ attr_reader :unmatched_paths
58
+
59
+ # Routes seen in the table but not documentable, by reason (redirect,
60
+ # mount). Set by collect_routes; exposed so a run can be reconciled.
61
+ attr_accessor :skipped_routes
62
+
63
+ # The command that runs the suite. An explicit `test.command` is used as
64
+ # written (globs expanded, since no shell is involved); otherwise a runner
65
+ # is built from `test.paths`: `bin/rails test` when the application has
66
+ # one, a plain Ruby runner when it does not, `rspec` for request specs.
67
+ def test_argv
68
+ explicit = configuration.test_command
69
+ return expand_globs(Shellwords.split(explicit)) if explicit
70
+
71
+ paths = configuration.test_paths
72
+ return %w[bin/rails test] if paths.empty?
73
+
74
+ case configuration.test_framework.to_s
75
+ when "rspec"
76
+ ["rspec", *paths]
77
+ else
78
+ if configuration.root.join("bin", "rails").exist?
79
+ ["bin/rails", "test", *paths]
80
+ else
81
+ ["ruby", "-Itest", "-e", "ARGV.each { |file| require File.expand_path(file) }", *test_files(paths)]
82
+ end
83
+ end
84
+ end
85
+
86
+ # Builds the dataset from exchanges that were captured earlier, without
87
+ # running the suite again.
88
+ def build_dataset(routes, exchanges)
89
+ endpoints = {}
90
+ @unmatched_paths = []
91
+
92
+ # A route answering any verb is documented once per verb a test used.
93
+ routes.each do |route|
94
+ endpoints[[route, route.method]] = route.endpoint unless route.any_verb?
95
+ end
96
+
97
+ exchanges.each do |raw_exchange|
98
+ sanitized =
99
+ Sanitizers::Sanitizer.call(
100
+ raw_exchange,
101
+ configuration: configuration
102
+ )
103
+
104
+ route = find_route(routes, sanitized)
105
+ request = sanitized.fetch("request")
106
+
107
+ unless route
108
+ @unmatched_paths << "#{request['method']} #{request['path']}"
109
+ next
110
+ end
111
+
112
+ verb = route.any_verb? ? request.fetch("method").to_s.upcase : route.method
113
+ endpoint = endpoints[[route, verb]] ||= route.endpoint(method: verb)
114
+
115
+ attach_exchange(endpoint, sanitized)
116
+ end
117
+
118
+ routes.select(&:any_verb?).each do |route|
119
+ next if endpoints.keys.any? { |(seen, _verb)| seen == route }
120
+
121
+ endpoints[[route, RouteCollector::ANY]] = route.endpoint
122
+ end
123
+
124
+ Dataset.new(
125
+ endpoints: Dataset.fold_method_twins(endpoints.values)
126
+ )
127
+ end
128
+
129
+ private
130
+
131
+ attr_reader :resources,
132
+ :version,
133
+ :configuration
134
+
135
+ # Silence here is the worst outcome: an empty page set looks the same
136
+ # whether the API has no tests or the capture never ran.
137
+ def report(exchanges, dataset)
138
+ if exchanges.empty?
139
+ Reqcord.warn("no request was captured")
140
+ Reqcord.warn(" is reqcord in the :test group of your Gemfile, and does `test.command` run integration tests?")
141
+ return
142
+ end
143
+
144
+ covered = dataset.curl_ready_endpoints.size
145
+ uncovered = dataset.uncovered_endpoints.size
146
+ matched = exchanges.size - unmatched_paths.size
147
+ skipped = skipped_routes || {}
148
+ skipped_total = skipped.values.sum
149
+
150
+ Reqcord.log("captured #{exchanges.size} request(s), #{matched} matched a documented route")
151
+ Reqcord.log("captured a successful 2xx request for #{covered} of #{dataset.endpoints.size} endpoint(s)")
152
+
153
+ # Every route lands in exactly one bucket; the sum is the proof.
154
+ Reqcord.log(
155
+ "routes: #{dataset.endpoints.size + skipped_total} = " \
156
+ "#{covered} documented + #{uncovered} uncovered + #{skipped_total} skipped"
157
+ )
158
+
159
+ if skipped_total.positive?
160
+ reasons = skipped.map { |reason, count| "#{count} #{reason}" }.join(", ")
161
+ Reqcord.log("skipped #{skipped_total} route(s) that cannot be documented: #{reasons}")
162
+ end
163
+
164
+ return if unmatched_paths.empty?
165
+
166
+ shown = unmatched_paths.uniq.first(5)
167
+
168
+ Reqcord.log("#{unmatched_paths.uniq.size} path(s) matched no documented route, for example:")
169
+ shown.each { |path| Reqcord.log(" #{path}") }
170
+ end
171
+
172
+ FRAMEWORKS = %w[minitest rspec].freeze
173
+
174
+ def validate!
175
+ unless FRAMEWORKS.include?(configuration.test_framework.to_s)
176
+ raise ConfigurationError,
177
+ "unsupported test framework: #{configuration.test_framework.inspect}, " \
178
+ "expected one of #{FRAMEWORKS.join(', ')}"
179
+ end
180
+
181
+ configuration.exporters.each { |name| Exporters.fetch(name) }
182
+
183
+ nil
184
+ end
185
+
186
+ def collect_routes
187
+ collector = RouteCollector.new(
188
+ resources: resources,
189
+ version: version,
190
+ prefix: configuration.route_prefix
191
+ )
192
+
193
+ routes = collector.call
194
+ @skipped_routes = collector.skipped
195
+
196
+ routes
197
+ end
198
+
199
+ # A directory means every *_test.rb (or *_spec.rb) beneath it.
200
+ def test_files(paths)
201
+ pattern = configuration.test_framework.to_s == "rspec" ? "*_spec.rb" : "*_test.rb"
202
+
203
+ paths.flat_map do |path|
204
+ if configuration.root.join(path).directory?
205
+ Dir.glob(File.join(path, "**", pattern), base: configuration.root.to_s).sort
206
+ else
207
+ expand_globs([path])
208
+ end
209
+ end
210
+ end
211
+
212
+ def expand_globs(args)
213
+ args.flat_map do |arg|
214
+ next [arg] unless arg.match?(/[*?\[{]/)
215
+
216
+ matches = Dir.glob(arg, base: configuration.root.to_s).sort
217
+ matches.empty? ? [arg] : matches
218
+ end
219
+ end
220
+
221
+ def build_capture_file
222
+ Reqcord.root.join(
223
+ "tmp",
224
+ "reqcord",
225
+ "#{SecureRandom.hex(12)}.ndjson"
226
+ ).to_s
227
+ end
228
+
229
+ def run_tests(capture_file)
230
+ FileUtils.mkdir_p(
231
+ File.dirname(capture_file)
232
+ )
233
+
234
+ env = {
235
+ "REQCORD_CAPTURE" => "1",
236
+ "REQCORD_CAPTURE_FILE" => capture_file
237
+ }
238
+
239
+ command = test_argv
240
+
241
+ Reqcord.log("running #{Shellwords.join(command)}")
242
+
243
+ # Streamed rather than captured: a long suite should print as it runs.
244
+ status =
245
+ Open3.popen2e(
246
+ env,
247
+ *command,
248
+ chdir: Reqcord.root.to_s
249
+ ) do |stdin, output, wait_thread|
250
+ stdin.close
251
+
252
+ output.each_line { |line| $stdout.print(line) }
253
+
254
+ wait_thread.value
255
+ end
256
+
257
+ return if status.success?
258
+
259
+ raise GenerationError,
260
+ "Test suite failed while generating Reqcord documentation"
261
+ end
262
+
263
+ def read_exchanges(capture_file)
264
+ return [] unless File.exist?(capture_file)
265
+
266
+ File.readlines(capture_file)
267
+ .filter_map do |line|
268
+ line = line.strip
269
+
270
+ next if line.empty?
271
+
272
+ begin
273
+ JSON.parse(line)
274
+ rescue JSON::ParserError
275
+ Reqcord.warn("skipping malformed capture line")
276
+ nil
277
+ end
278
+ end
279
+ end
280
+
281
+ def find_route(routes, exchange)
282
+ request = exchange.fetch("request")
283
+ method = request.fetch("method")
284
+ path = request.fetch("path")
285
+
286
+ routes.find { |route| route.matches?(method, path) }
287
+ rescue StandardError => e
288
+ raise GenerationError, "could not match #{method} #{path} against the route table: #{e.message}"
289
+ end
290
+
291
+ def attach_exchange(endpoint, exchange)
292
+ request_data = exchange.fetch("request")
293
+ response_data = exchange.fetch("response")
294
+ source = normalize_source(exchange["source"])
295
+ status = response_data.fetch("status")
296
+
297
+ request =
298
+ RequestExample.new(
299
+ name: example_name(source),
300
+ method: request_data.fetch("method"),
301
+ # The concrete path, not the route pattern: the cURL must be runnable.
302
+ path: request_data.fetch("path"),
303
+ path_params: request_data["path_params"] || {},
304
+ query_params: request_data["query_params"] || {},
305
+ headers: request_data["headers"] || {},
306
+ body: request_data["body"],
307
+ content_type: request_data["content_type"],
308
+ response_status: status,
309
+ source: source
310
+ )
311
+
312
+ response =
313
+ ResponseExample.new(
314
+ name: example_name(source),
315
+ status: status,
316
+ headers: response_data["headers"] || {},
317
+ body: response_data["body"],
318
+ content_type: response_data["content_type"],
319
+ source: source
320
+ )
321
+
322
+ endpoint.add_exchange(
323
+ request: request,
324
+ response: response
325
+ )
326
+ end
327
+
328
+ # "test_creates_customer" -> "Creates Customer"
329
+ def example_name(source)
330
+ test = source["test"].to_s.sub(/\Atest_/, "")
331
+
332
+ test.empty? ? nil : Support.titleize(test)
333
+ end
334
+
335
+ def normalize_source(source)
336
+ source = (source || {}).transform_keys(&:to_s)
337
+ file = source["file"].to_s
338
+ prefix = "#{Reqcord.root}/"
339
+
340
+ source["file"] = file.delete_prefix(prefix) unless file.empty?
341
+ source.compact
342
+ end
343
+
344
+ def write_outputs(dataset)
345
+ output = configuration.output_directory
346
+
347
+ FileUtils.mkdir_p(output)
348
+
349
+ written = [dataset.write(output.join("dataset.json"))]
350
+
351
+ configuration.exporters.each do |name|
352
+ written.concat(
353
+ Exporters.fetch(name).call(
354
+ dataset: dataset,
355
+ output_dir: output,
356
+ configuration: configuration
357
+ )
358
+ )
359
+ end
360
+
361
+ written
362
+ end
363
+ end
364
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ load File.expand_path(
7
+ "../tasks/reqcord.rake",
8
+ __dir__
9
+ )
10
+ end
11
+
12
+ initializer "reqcord.capture" do
13
+ next unless Reqcord::Capture.enabled?
14
+
15
+ require "action_dispatch/testing/integration"
16
+ require_relative "capture/integration_patch"
17
+
18
+ unless ActionDispatch::Integration::Session <
19
+ Reqcord::Capture::IntegrationPatch
20
+
21
+ ActionDispatch::Integration::Session.prepend(
22
+ Reqcord::Capture::IntegrationPatch
23
+ )
24
+ end
25
+
26
+ # Capture itself is framework agnostic; only naming the example needs to
27
+ # know which test framework is running.
28
+ case Reqcord.configuration.test_framework.to_s
29
+ when "minitest"
30
+ require "minitest/test"
31
+ require_relative "capture/minitest_context"
32
+
33
+ unless Minitest::Test < Reqcord::Capture::MinitestContext
34
+ Minitest::Test.prepend(
35
+ Reqcord::Capture::MinitestContext
36
+ )
37
+ end
38
+
39
+ when "rspec"
40
+ require "rspec/core"
41
+ require_relative "capture/rspec_context"
42
+
43
+ Reqcord::Capture::RSpecContext.install!
44
+
45
+ else
46
+ raise ConfigurationError,
47
+ "unsupported test framework: #{Reqcord.configuration.test_framework.inspect}"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Renderers
5
+ class Curl
6
+ def self.call(request, base_url:)
7
+ new(
8
+ request,
9
+ base_url: base_url
10
+ ).call
11
+ end
12
+
13
+ def initialize(request, base_url:)
14
+ @request = request
15
+ @base_url = base_url.to_s.sub(%r{/$}, "")
16
+ end
17
+
18
+ def call
19
+ parts = [
20
+ "curl --request #{request.method}",
21
+ %(--url "#{url}")
22
+ ]
23
+
24
+ request.headers.each do |key, value|
25
+ parts << %(--header "#{key}: #{escape_header(value)}")
26
+ end
27
+
28
+ parts << data_argument if request.body?
29
+
30
+ parts.join(" \\\n ")
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :request, :base_url
36
+
37
+ def url
38
+ "#{base_url}#{Payload.path_with_query(request)}"
39
+ end
40
+
41
+ def data_argument
42
+ "--data '#{shell_single_quote(Payload.raw_body(request))}'"
43
+ end
44
+
45
+ def shell_single_quote(value)
46
+ value.to_s.gsub("'", %q('"'"'))
47
+ end
48
+
49
+ def escape_header(value)
50
+ value.to_s
51
+ .gsub("\\") { "\\\\" }
52
+ .gsub('"') { '\\"' }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Renderers
5
+ # The wire-level decisions every output format has to agree on: whether a
6
+ # request is JSON, how a nested query flattens, what the body looks like
7
+ # as text. cURL, Postman and any later exporter read these, never their
8
+ # own copy.
9
+ module Payload
10
+ module_function
11
+
12
+ def json?(request)
13
+ request.content_type.to_s.include?("json") ||
14
+ request.headers.any? do |key, value|
15
+ key.to_s.casecmp?("Content-Type") && value.to_s.include?("json")
16
+ end
17
+ end
18
+
19
+ # Rails bracket notation: { filter: { status: "a" }, ids: [1, 2] } becomes
20
+ # [["filter[status]", "a"], ["ids[]", 1], ["ids[]", 2]].
21
+ def flatten_query(hash, prefix = nil)
22
+ hash.flat_map do |key, value|
23
+ current = prefix ? "#{prefix}[#{key}]" : key.to_s
24
+
25
+ case value
26
+ when Hash
27
+ flatten_query(value, current)
28
+ when Array
29
+ value.flat_map do |item|
30
+ if item.is_a?(Hash)
31
+ flatten_query(item, "#{current}[]")
32
+ else
33
+ [["#{current}[]", item]]
34
+ end
35
+ end
36
+ else
37
+ [[current, value]]
38
+ end
39
+ end
40
+ end
41
+
42
+ def query_pairs(request)
43
+ flatten_query(request.query_params)
44
+ end
45
+
46
+ def form_pairs(request)
47
+ request.body.is_a?(Hash) ? flatten_query(request.body) : []
48
+ end
49
+
50
+ def path_with_query(request)
51
+ return request.path if request.query_params.empty?
52
+
53
+ "#{request.path}?#{URI.encode_www_form(query_pairs(request))}"
54
+ end
55
+
56
+ # The body as it goes on the wire: pretty JSON, a form string, or the
57
+ # raw text the test sent.
58
+ def raw_body(request)
59
+ if json?(request)
60
+ JSON.pretty_generate(request.body)
61
+ elsif request.body.is_a?(Hash)
62
+ URI.encode_www_form(form_pairs(request))
63
+ else
64
+ request.body.to_s
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ class RequestExample
5
+ ATTRIBUTES = %i[
6
+ name
7
+ method
8
+ path
9
+ path_params
10
+ query_params
11
+ headers
12
+ body
13
+ content_type
14
+ response_status
15
+ source
16
+ ].freeze
17
+
18
+ attr_accessor(*ATTRIBUTES)
19
+
20
+ def initialize(
21
+ method:,
22
+ path:,
23
+ name: nil,
24
+ path_params: {},
25
+ query_params: {},
26
+ headers: {},
27
+ body: nil,
28
+ content_type: nil,
29
+ response_status: nil,
30
+ source: {}
31
+ )
32
+ @name = name
33
+ @method = method.to_s.upcase
34
+ @path = path
35
+ @path_params = path_params || {}
36
+ @query_params = query_params || {}
37
+ @headers = headers || {}
38
+ @body = body
39
+ @content_type = content_type
40
+ @response_status = response_status&.to_i
41
+ @source = source || {}
42
+ end
43
+
44
+ # `method` is the HTTP verb here; this alias keeps call sites that mean the
45
+ # verb from reading like reflection.
46
+ def http_method
47
+ method
48
+ end
49
+
50
+ # A payload of empty containers ({"experience" => {}}) carries nothing to
51
+ # document, and sending it as --data would only mislead.
52
+ def body?
53
+ meaningful?(body)
54
+ end
55
+
56
+ # The request the endpoint page leads with should be one that worked.
57
+ def successful?
58
+ response_status.nil? ? false : (200..299).cover?(response_status)
59
+ end
60
+
61
+ # Identical requests captured by several tests are stored once — but the
62
+ # same request that produced a different status is a different example.
63
+ # Sanitization can make a right and a wrong password look identical; the
64
+ # 200 must not be dropped because the 401 was captured first.
65
+ def signature
66
+ JSON.generate([method, path, headers, query_params, body, response_status])
67
+ end
68
+
69
+ def to_h
70
+ {
71
+ name: name,
72
+ method: method,
73
+ path: path,
74
+ path_params: path_params,
75
+ query_params: query_params,
76
+ headers: headers,
77
+ body: body,
78
+ content_type: content_type,
79
+ response_status: response_status,
80
+ source: source
81
+ }
82
+ end
83
+
84
+ # Tolerates keys this version does not know, so a dataset written by a
85
+ # newer Reqcord still loads.
86
+ def self.from_h(hash)
87
+ hash = hash.transform_keys(&:to_sym).slice(*ATTRIBUTES)
88
+
89
+ new(**hash)
90
+ end
91
+
92
+ private
93
+
94
+ def meaningful?(value)
95
+ case value
96
+ when nil then false
97
+ when Hash then value.any? { |_key, nested| meaningful?(nested) }
98
+ when Array then value.any? { |item| meaningful?(item) }
99
+ when String then !value.empty?
100
+ else true
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ class ResponseExample
5
+ ATTRIBUTES = %i[
6
+ name
7
+ status
8
+ headers
9
+ body
10
+ content_type
11
+ source
12
+ ].freeze
13
+
14
+ attr_accessor(*ATTRIBUTES)
15
+
16
+ def initialize(
17
+ status:,
18
+ name: nil,
19
+ headers: {},
20
+ body: nil,
21
+ content_type: nil,
22
+ source: {}
23
+ )
24
+ @name = name
25
+ @status = status.to_i
26
+ @headers = headers || {}
27
+ @body = body
28
+ @content_type = content_type
29
+ @source = source || {}
30
+ end
31
+
32
+ # "201 Created"
33
+ def status_text
34
+ Rack::Utils::HTTP_STATUS_CODES[status] || "Unknown"
35
+ end
36
+
37
+ def title
38
+ "#{status} #{status_text}"
39
+ end
40
+
41
+ def body?
42
+ !(body.nil? || (body.respond_to?(:empty?) && body.empty?))
43
+ end
44
+
45
+ def empty_body?
46
+ !body?
47
+ end
48
+
49
+ def signature
50
+ JSON.generate([status, body])
51
+ end
52
+
53
+ def to_h
54
+ {
55
+ name: name,
56
+ status: status,
57
+ headers: headers,
58
+ body: body,
59
+ content_type: content_type,
60
+ source: source
61
+ }
62
+ end
63
+
64
+ # Tolerates keys this version does not know, so a dataset written by a
65
+ # newer Reqcord still loads.
66
+ def self.from_h(hash)
67
+ hash = hash.transform_keys(&:to_sym).slice(*ATTRIBUTES)
68
+
69
+ new(**hash)
70
+ end
71
+ end
72
+ end