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,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ class Configuration
5
+ DEFAULTS = {
6
+ "version" => 1,
7
+
8
+ # No default command: with neither `command` nor `paths` the generator
9
+ # falls back to `bin/rails test`, and `paths` alone must be able to win.
10
+ "test" => {
11
+ "framework" => "minitest"
12
+ },
13
+
14
+ "routes" => {
15
+ "prefix" => "/api"
16
+ },
17
+
18
+ "output" => {
19
+ "directory" => "docs/api",
20
+
21
+ # A route no test exercised is reported in the index; writing a page
22
+ # with nothing on it only adds noise.
23
+ "include_uncovered" => false
24
+ },
25
+
26
+ "exporters" => %w[curl markdown postman],
27
+
28
+ "variables" => {
29
+ "base_url" => "http://localhost:3000"
30
+ },
31
+
32
+ "sanitize" => {
33
+ "headers" => {
34
+ "Authorization" => "Bearer {{token}}",
35
+ "X-Api-Key" => "{{api_key}}"
36
+ },
37
+
38
+ "body" => {
39
+ "password" => "{{password}}",
40
+ "password_confirmation" => "{{password}}",
41
+ "token" => "{{token}}",
42
+ "access_token" => "{{token}}",
43
+ "refresh_token" => "{{token}}",
44
+ "api_key" => "{{api_key}}",
45
+ "secret" => "{{secret}}",
46
+ "client_secret" => "{{secret}}"
47
+ }
48
+ }
49
+ }.freeze
50
+
51
+ # Headers that describe the transport rather than the API. A documented
52
+ # cURL that carries them is worse than one that does not: `Host` alone
53
+ # would send the reader's request to the wrong virtual host.
54
+ NOISY_HEADERS = %w[
55
+ host
56
+ user-agent
57
+ connection
58
+ version
59
+ remote-addr
60
+ accept-encoding
61
+ cache-control
62
+ content-length
63
+ date
64
+ etag
65
+ server-timing
66
+ transfer-encoding
67
+ vary
68
+ x-content-type-options
69
+ x-download-options
70
+ x-frame-options
71
+ x-permitted-cross-domain-policies
72
+ x-request-id
73
+ x-runtime
74
+ x-xss-protection
75
+ referrer-policy
76
+ ].freeze
77
+
78
+ attr_reader :data, :root
79
+
80
+ def self.load(root:)
81
+ new(root: root).load
82
+ end
83
+
84
+ def initialize(root:)
85
+ @root = Pathname(root)
86
+ @data = deep_dup(DEFAULTS)
87
+ end
88
+
89
+ def load
90
+ path = root.join("reqcord.yml")
91
+
92
+ return self unless path.exist?
93
+
94
+ raw = YAML.safe_load_file(
95
+ path,
96
+ aliases: false
97
+ ) || {}
98
+
99
+ unless raw.is_a?(Hash)
100
+ raise ConfigurationError,
101
+ "reqcord.yml must contain a YAML object"
102
+ end
103
+
104
+ @data = deep_merge(@data, stringify_keys(raw))
105
+
106
+ self
107
+ end
108
+
109
+ def test_framework
110
+ ENV["REQCORD_TEST_FRAMEWORK"] ||
111
+ data.dig("test", "framework")
112
+ end
113
+
114
+ # An explicit command wins; nil means "build one from test.paths".
115
+ def test_command
116
+ value = ENV["REQCORD_TEST_COMMAND"] || data.dig("test", "command")
117
+
118
+ value.to_s.empty? ? nil : value.to_s
119
+ end
120
+
121
+ # Directories, files or globs the suite lives in; Reqcord picks the runner.
122
+ def test_paths
123
+ Array(data.dig("test", "paths")).map(&:to_s)
124
+ end
125
+
126
+ def route_prefix
127
+ data.dig("routes", "prefix")
128
+ end
129
+
130
+ def output_directory
131
+ value =
132
+ ENV["REQCORD_OUTPUT"] ||
133
+ data.dig("output", "directory") ||
134
+ "docs/api"
135
+
136
+ root.join(value)
137
+ end
138
+
139
+ def include_uncovered?
140
+ data.dig("output", "include_uncovered") == true
141
+ end
142
+
143
+ def exporters
144
+ Array(data["exporters"]).map(&:to_s)
145
+ end
146
+
147
+ def variables
148
+ (data["variables"] || {}).merge(
149
+ "base_url" => base_url
150
+ )
151
+ end
152
+
153
+ def base_url
154
+ ENV["REQCORD_BASE_URL"] ||
155
+ data.dig("variables", "base_url") ||
156
+ "http://localhost:3000"
157
+ end
158
+
159
+ def sanitized_headers
160
+ data.dig("sanitize", "headers") || {}
161
+ end
162
+
163
+ def sanitized_body_keys
164
+ data.dig("sanitize", "body") || {}
165
+ end
166
+
167
+ def noisy_header?(key)
168
+ NOISY_HEADERS.include?(key.to_s.downcase)
169
+ end
170
+
171
+ private
172
+
173
+ def deep_merge(left, right)
174
+ left.merge(right) do |_key, old_value, new_value|
175
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
176
+ deep_merge(old_value, new_value)
177
+ else
178
+ new_value
179
+ end
180
+ end
181
+ end
182
+
183
+ def deep_dup(value)
184
+ Marshal.load(Marshal.dump(value))
185
+ end
186
+
187
+ def stringify_keys(hash)
188
+ hash.each_with_object({}) do |(key, value), result|
189
+ result[key.to_s] =
190
+ if value.is_a?(Hash)
191
+ stringify_keys(value)
192
+ else
193
+ value
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ class Dataset
5
+ # 2: endpoints carry `parameters` (path/query/body schemas), `responses`
6
+ # (one schema + example per status), `route_name` and `also_methods`.
7
+ SCHEMA_VERSION = 2
8
+
9
+ # An endpoint page must not overwrite the resource or dataset pages.
10
+ RESERVED_BASENAMES = {
11
+ "index" => "list",
12
+ "readme" => "readme-endpoint",
13
+ "dataset" => "dataset-endpoint"
14
+ }.freeze
15
+
16
+ # Endpoints served by one controller, as the exporters walk them. Keyed by
17
+ # the full controller path so `admin/users` and `api/v1/users` stay apart.
18
+ class Resource
19
+ attr_reader :name, :endpoints
20
+
21
+ def initialize(name)
22
+ @name = name.to_s
23
+ @endpoints = []
24
+ end
25
+
26
+ def segments
27
+ name.split("/")
28
+ end
29
+
30
+ def short_name
31
+ segments.last.to_s
32
+ end
33
+
34
+ def namespace
35
+ segments[0...-1].join("/")
36
+ end
37
+
38
+ # Nested directories / folders: api/v2/customers.
39
+ def slug
40
+ segments.map { |segment| Support.parameterize(segment) }.join("/")
41
+ end
42
+
43
+ def title
44
+ Support.titleize(short_name)
45
+ end
46
+
47
+ def api_versions
48
+ endpoints.map(&:api_version).compact.uniq.sort
49
+ end
50
+
51
+ # One stable file name per endpoint. Two routes sharing an action
52
+ # (`match … via: [:get, :post]`) are told apart by verb, never by a
53
+ # bare counter.
54
+ def file_basenames
55
+ bases = endpoints.to_h { |endpoint| [endpoint, RESERVED_BASENAMES.fetch(endpoint.slug, endpoint.slug)] }
56
+ shared = bases.values.tally
57
+ seen = Hash.new(0)
58
+
59
+ bases.to_h do |endpoint, base|
60
+ basename = shared[base] > 1 ? "#{base}-#{endpoint.method.downcase}" : base
61
+ seen[basename] += 1
62
+ basename = "#{basename}-#{seen[basename]}" if seen[basename] > 1
63
+
64
+ [endpoint, basename]
65
+ end
66
+ end
67
+ end
68
+
69
+ # Rails emits both PATCH and PUT for `update`; one page serves both. The
70
+ # verb a test used wins, PATCH when neither or both did.
71
+ def self.fold_method_twins(endpoints)
72
+ groups = endpoints.group_by { |endpoint| [endpoint.path, endpoint.controller, endpoint.action] }
73
+
74
+ endpoints.filter_map do |endpoint|
75
+ next endpoint unless %w[PATCH PUT].include?(endpoint.method)
76
+
77
+ twins = groups.fetch([endpoint.path, endpoint.controller, endpoint.action])
78
+ .select { |candidate| %w[PATCH PUT].include?(candidate.method) }
79
+
80
+ next endpoint unless twins.size == 2
81
+
82
+ primary = twins.find(&:curl_ready?) || twins.find { |candidate| candidate.method == "PATCH" }
83
+
84
+ next nil unless endpoint.equal?(primary)
85
+
86
+ twin = twins.find { |candidate| !candidate.equal?(primary) }
87
+ twin.request_examples.each { |example| primary.add_request_example(example) }
88
+ twin.response_examples.each { |example| primary.add_response_example(example) }
89
+ primary.also_methods = [twin.method]
90
+
91
+ primary
92
+ end
93
+ end
94
+
95
+ attr_reader :schema_version, :endpoints
96
+
97
+ def initialize(
98
+ schema_version: SCHEMA_VERSION,
99
+ endpoints: []
100
+ )
101
+ @schema_version = schema_version
102
+ @endpoints = endpoints
103
+ end
104
+
105
+ def add(endpoint)
106
+ endpoints << endpoint
107
+ end
108
+
109
+ def empty?
110
+ endpoints.empty?
111
+ end
112
+
113
+ # Endpoints for which Reqcord captured at least one successful 2xx
114
+ # request. These are safe to use as canonical cURL/documentation examples.
115
+ def curl_ready_endpoints
116
+ endpoints.select(&:curl_ready?)
117
+ end
118
+
119
+ # Backwards-compatible alias for callers that previously asked for
120
+ # documented endpoints. In the cURL-first MVP, documented means a
121
+ # successful request was actually captured.
122
+ def documented_endpoints
123
+ curl_ready_endpoints
124
+ end
125
+
126
+ def uncovered_endpoints
127
+ endpoints.reject(&:curl_ready?)
128
+ end
129
+
130
+ def resources
131
+ grouped = endpoints.each_with_object({}) do |endpoint, memo|
132
+ key = endpoint.controller.to_s.empty? ? endpoint.resource : endpoint.controller
133
+ resource = memo[key] ||= Resource.new(key)
134
+ resource.endpoints << endpoint
135
+ end
136
+
137
+ grouped.values.sort_by(&:name).each do |resource|
138
+ resource.endpoints.sort_by! { |endpoint| [endpoint.path, endpoint.method] }
139
+ end
140
+ end
141
+
142
+ def to_h
143
+ {
144
+ schema_version: schema_version,
145
+ generated_at: Time.now.utc.iso8601,
146
+ endpoints: curl_ready_endpoints.map(&:to_h),
147
+ uncovered_routes: uncovered_endpoints.map do |endpoint|
148
+ {
149
+ name: endpoint.name,
150
+ method: endpoint.method,
151
+ path: endpoint.path,
152
+ controller: endpoint.controller,
153
+ action: endpoint.action,
154
+ resource: endpoint.resource,
155
+ api_version: endpoint.api_version,
156
+ route_name: endpoint.route_name,
157
+ also_methods: endpoint.also_methods
158
+ }
159
+ end
160
+ }
161
+ end
162
+
163
+ def write(path)
164
+ path = Pathname(path)
165
+
166
+ FileUtils.mkdir_p(path.dirname)
167
+
168
+ File.write(
169
+ path,
170
+ JSON.pretty_generate(to_h)
171
+ )
172
+
173
+ path.to_s
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ class Endpoint
5
+ ACTION_TITLES = {
6
+ "index" => "List %<plural>s",
7
+ "show" => "Get %<singular>s",
8
+ "create" => "Create %<singular>s",
9
+ "update" => "Update %<singular>s",
10
+ "destroy" => "Delete %<singular>s",
11
+ "new" => "New %<singular>s",
12
+ "edit" => "Edit %<singular>s"
13
+ }.freeze
14
+
15
+ # One status the endpoint was seen to return, described by every body
16
+ # captured with that status; the first capture stands as the example.
17
+ Response = Struct.new(:status, :schema, :examples, keyword_init: true) do
18
+ def example
19
+ examples.first
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ status: status,
25
+ schema: schema.to_a,
26
+ example: example.body,
27
+ headers: example.headers,
28
+ content_type: example.content_type
29
+ }
30
+ end
31
+ end
32
+
33
+ attr_accessor :name,
34
+ :method,
35
+ :path,
36
+ :controller,
37
+ :action,
38
+ :resource,
39
+ :api_version,
40
+ :route_name,
41
+ :also_methods,
42
+ :request_examples,
43
+ :response_examples
44
+
45
+ def initialize(
46
+ method:,
47
+ path:,
48
+ controller:,
49
+ action:,
50
+ name: nil,
51
+ resource: nil,
52
+ api_version: nil,
53
+ route_name: nil,
54
+ also_methods: [],
55
+ request_examples: [],
56
+ response_examples: []
57
+ )
58
+ @method = method.to_s.upcase
59
+ @path = path
60
+ @controller = controller
61
+ @action = action
62
+ @resource = resource || infer_resource(controller)
63
+ @api_version = api_version
64
+ @route_name = route_name
65
+ @also_methods = also_methods
66
+ @request_examples = request_examples
67
+ @response_examples = response_examples
68
+ @name = name || default_name
69
+ end
70
+
71
+ def http_method
72
+ method
73
+ end
74
+
75
+ def key
76
+ "#{method} #{path}"
77
+ end
78
+
79
+ # A request only counts as successful through the status it received, so
80
+ # the pair is what tells us; callers should not have to set it by hand.
81
+ def add_exchange(request:, response:)
82
+ request.response_status ||= response.status if request && response
83
+
84
+ add_request_example(request)
85
+ add_response_example(response)
86
+ end
87
+
88
+ def add_request_example(example)
89
+ return if example.nil?
90
+ return if request_examples.any? { |candidate| candidate.signature == example.signature }
91
+
92
+ reset_schemas!
93
+ request_examples << example
94
+ end
95
+
96
+ def add_response_example(example)
97
+ return if example.nil?
98
+ return if response_examples.any? { |candidate| candidate.signature == example.signature }
99
+
100
+ response_examples << example
101
+ end
102
+
103
+ def documented?
104
+ !request_examples.empty? || !response_examples.empty?
105
+ end
106
+
107
+ # cURL examples must come from a request that the application actually
108
+ # accepted. Error-case payloads are valuable response examples, but they
109
+ # must never become the endpoint's canonical request example.
110
+ def primary_request_example
111
+ successful_request_examples.first
112
+ end
113
+
114
+ def curl_ready?
115
+ !primary_request_example.nil?
116
+ end
117
+
118
+ def successful_request_examples
119
+ request_examples.select(&:successful?)
120
+ end
121
+
122
+ def documented_request_examples
123
+ successful = successful_request_examples
124
+ successful.empty? ? request_examples : successful
125
+ end
126
+
127
+ def responses_by_status
128
+ response_examples.group_by(&:status).sort_by { |status, _| status }.to_h
129
+ end
130
+
131
+ # Every status seen, each with a schema inferred from all of its bodies.
132
+ # Non-JSON bodies (plain text, HTML) carry no fields to describe.
133
+ def responses
134
+ responses_by_status.map do |status, examples|
135
+ bodies = examples.map(&:body).select { |body| body.is_a?(Hash) || body.is_a?(Array) }
136
+
137
+ Response.new(status: status, schema: Schema.infer(bodies, repetition: false), examples: examples)
138
+ end
139
+ end
140
+
141
+ # What the endpoint accepts, described only by requests the application
142
+ # accepted: a rejected payload says what the API refuses, not what it takes.
143
+ def body_schema
144
+ @body_schema ||= Schema.infer(successful_request_examples.map(&:body))
145
+ end
146
+
147
+ def query_schema
148
+ @query_schema ||= Schema.infer(successful_request_examples.map(&:query_params))
149
+ end
150
+
151
+ def path_param_schema
152
+ @path_param_schema ||= Schema.infer(successful_request_examples.map(&:path_params))
153
+ end
154
+
155
+ # A member route addresses one record: a required `:param` follows the
156
+ # resource segment. Optional groups (`/items(/:id)`) and globs
157
+ # (`/files/*path`) do not make a route a member route.
158
+ def member?
159
+ segments = required_path.split("/").reject(&:empty?)
160
+ singular = Support.singularize(resource.to_s)
161
+ index = segments.rindex { |segment| segment == resource.to_s || segment == singular }
162
+
163
+ # `resource :cart` is served by CartsController at /cart: one record,
164
+ # so its custom actions (/cart/checkout) address that one record.
165
+ return true if index && segments[index] == singular && singular != resource.to_s
166
+
167
+ candidates = index ? segments[(index + 1)..] : segments
168
+
169
+ candidates.any? { |segment| segment.start_with?(":") }
170
+ end
171
+
172
+ # Every dynamic segment, including globs and those inside optional groups.
173
+ def path_params
174
+ path.scan(/[:*]([a-zA-Z_][a-zA-Z0-9_]*)/).flatten
175
+ end
176
+
177
+ # The path with its optional groups removed: what a request must carry.
178
+ def required_path
179
+ stripped = path.to_s
180
+
181
+ stripped = stripped.gsub(/\([^()]*\)/, "") while stripped.match?(/\([^()]*\)/)
182
+
183
+ stripped
184
+ end
185
+
186
+ def slug
187
+ Support.parameterize(action.to_s.empty? ? key : action)
188
+ end
189
+
190
+ def to_h
191
+ {
192
+ name: name,
193
+ method: method,
194
+ path: path,
195
+ controller: controller,
196
+ action: action,
197
+ resource: resource,
198
+ api_version: api_version,
199
+ route_name: route_name,
200
+ also_methods: also_methods,
201
+ path_params: path_params,
202
+ parameters: {
203
+ path: path_param_schema.to_a,
204
+ query: query_schema.to_a,
205
+ body: body_schema.to_a
206
+ },
207
+ responses: responses.map(&:to_h),
208
+ request_examples: request_examples.map(&:to_h),
209
+ response_examples: response_examples.map(&:to_h)
210
+ }
211
+ end
212
+
213
+ private
214
+
215
+ def reset_schemas!
216
+ @body_schema = nil
217
+ @query_schema = nil
218
+ @path_param_schema = nil
219
+ end
220
+
221
+ def infer_resource(controller)
222
+ controller.to_s.split("/").last
223
+ end
224
+
225
+ def default_name
226
+ label = resource.to_s.empty? ? "resource" : resource.to_s.tr("/", " ")
227
+ plural = Support.titleize(Support.pluralize(label))
228
+ singular = Support.titleize(Support.singularize(label))
229
+
230
+ # `root to: "home#index"` is a page, not a list of homes.
231
+ return singular if action.to_s == "index" && !resource_in_path?
232
+
233
+ template = ACTION_TITLES[action.to_s]
234
+ return format(template, plural: plural, singular: singular) if template
235
+ return plural if action.to_s.empty?
236
+
237
+ # `post "auth/login", to: "auth#login"`: the action is the page, the
238
+ # controller is only where it lives — "Login", not "Login Auths".
239
+ return Support.titleize(action) if singular_resource? && action_in_path?
240
+
241
+ "#{Support.titleize(action)} #{member? || singular_resource? ? singular : plural}".strip
242
+ end
243
+
244
+ # A controller named for one thing (auth, home, health) rather than a
245
+ # collection (customers).
246
+ def singular_resource?
247
+ Support.pluralize(resource.to_s) != resource.to_s
248
+ end
249
+
250
+ def action_in_path?
251
+ required_path.split("/").last.to_s == action.to_s
252
+ end
253
+
254
+ # Whether the path itself names the resource (/customers, /cart), as
255
+ # opposed to a route like `/` or `/dashboard` served by some controller.
256
+ def resource_in_path?
257
+ static = required_path.split("/").reject { |segment| segment.empty? || segment.start_with?(":", "*") }
258
+ names = [resource.to_s, Support.singularize(resource.to_s)]
259
+
260
+ static.any? { |segment| names.include?(segment) }
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class GenerationError < Error; end
9
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Exporters
5
+ # Writes one runnable .sh file per covered endpoint. The command is always
6
+ # rendered from the endpoint's successful captured request when available.
7
+ class Curl
8
+ def self.call(dataset:, output_dir:, configuration:)
9
+ new(
10
+ dataset: dataset,
11
+ output_dir: output_dir,
12
+ configuration: configuration
13
+ ).call
14
+ end
15
+
16
+ def initialize(dataset:, output_dir:, configuration:)
17
+ @dataset = dataset
18
+ @output_dir = Pathname(output_dir).join("curl")
19
+ @configuration = configuration
20
+ end
21
+
22
+ def call
23
+ FileUtils.mkdir_p(output_dir)
24
+
25
+ dataset.resources.flat_map do |resource|
26
+ basenames = resource.file_basenames
27
+
28
+ resource.endpoints.filter_map do |endpoint|
29
+ next unless endpoint.curl_ready?
30
+
31
+ example = endpoint.primary_request_example
32
+
33
+ directory = output_dir.join(resource.slug)
34
+ FileUtils.mkdir_p(directory)
35
+
36
+ path = directory.join("#{basenames.fetch(endpoint)}.sh")
37
+ File.write(path, script(endpoint, example))
38
+ File.chmod(0o755, path)
39
+
40
+ path.to_s
41
+ end
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :dataset, :output_dir, :configuration
48
+
49
+ def script(endpoint, example)
50
+ command = Renderers::Curl.call(
51
+ example,
52
+ base_url: configuration.base_url
53
+ )
54
+
55
+ <<~SH
56
+ #!/usr/bin/env bash
57
+ set -euo pipefail
58
+
59
+ # #{endpoint.name}
60
+ # #{endpoint.method} #{endpoint.path}
61
+ #{command}
62
+ SH
63
+ end
64
+ end
65
+
66
+ register("curl", Curl)
67
+ end
68
+ end