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,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ # The documented surface is the route table, not the captured traffic: every
5
+ # matching route becomes an endpoint, and captures are attached to it. Routes
6
+ # no test exercised stay in the dataset as documentation gaps.
7
+ #
8
+ # Nothing is dropped silently: a route either becomes a Route, or is counted
9
+ # in `skipped` with the reason (redirect, Rack mount, Rails internal).
10
+ class RouteCollector
11
+ # A route that answers any verb (`via: :all`); the captured verb decides
12
+ # what gets documented.
13
+ ANY = "ANY"
14
+
15
+ Route = Struct.new(
16
+ :name,
17
+ :method,
18
+ :path,
19
+ :controller,
20
+ :action,
21
+ :resource,
22
+ :api_version,
23
+ :rails_route,
24
+ :mount_prefix,
25
+ keyword_init: true
26
+ ) do
27
+ def endpoint(method: self.method)
28
+ Endpoint.new(
29
+ method: method,
30
+ path: path,
31
+ controller: controller,
32
+ action: action,
33
+ resource: resource,
34
+ api_version: api_version,
35
+ route_name: name
36
+ )
37
+ end
38
+
39
+ def any_verb?
40
+ method == ANY
41
+ end
42
+
43
+ def matches?(request_method, request_path)
44
+ return false unless any_verb? || method == request_method.to_s.upcase
45
+
46
+ relative = relative_path(request_path)
47
+ return false if relative.nil?
48
+
49
+ !!rails_route.path.match(relative)
50
+ end
51
+
52
+ private
53
+
54
+ # An engine's pattern knows nothing about where it was mounted.
55
+ def relative_path(request_path)
56
+ return request_path if mount_prefix.nil? || mount_prefix.empty?
57
+ return nil unless request_path.start_with?(mount_prefix)
58
+
59
+ rest = request_path.delete_prefix(mount_prefix)
60
+ return nil unless rest.empty? || rest.start_with?("/")
61
+
62
+ rest.empty? ? "/" : rest
63
+ end
64
+ end
65
+
66
+ def self.call(
67
+ resources: [],
68
+ version: nil,
69
+ prefix: nil,
70
+ route_set: nil
71
+ )
72
+ new(
73
+ resources: resources,
74
+ version: version,
75
+ prefix: prefix,
76
+ route_set: route_set
77
+ ).call
78
+ end
79
+
80
+ # Routes that were seen but cannot be documented, by reason.
81
+ attr_reader :skipped
82
+
83
+ def initialize(resources:, version:, prefix:, route_set: nil)
84
+ @resources = Array(resources).map(&:to_s)
85
+ @version = version&.to_s
86
+ @prefix = prefix
87
+ @route_set = route_set
88
+ @skipped = Hash.new(0)
89
+ end
90
+
91
+ def call
92
+ @skipped = Hash.new(0)
93
+
94
+ collect(route_set.routes, mount_prefix: nil)
95
+ end
96
+
97
+ def skipped_count
98
+ skipped.values.sum
99
+ end
100
+
101
+ private
102
+
103
+ attr_reader :resources, :version, :prefix
104
+
105
+ def route_set
106
+ @route_set ||= Rails.application.routes
107
+ end
108
+
109
+ def collect(rails_routes, mount_prefix:)
110
+ rails_routes.flat_map { |rails_route| build_routes(rails_route, mount_prefix) }
111
+ end
112
+
113
+ def build_routes(rails_route, mount_prefix)
114
+ return [] if rails_route.internal
115
+
116
+ app = rails_route.app
117
+ spec = normalize_path(rails_route.path.spec.to_s)
118
+
119
+ # A mounted engine's routes live in its own table, relative to the mount.
120
+ if engine?(app)
121
+ return collect(app.rack_app.routes.routes, mount_prefix: join(mount_prefix, spec))
122
+ end
123
+
124
+ controller = rails_route.defaults[:controller]&.to_s
125
+ action = rails_route.defaults[:action]&.to_s
126
+
127
+ if controller.nil? || action.nil?
128
+ @skipped[skip_reason(app)] += 1
129
+ return []
130
+ end
131
+
132
+ return [] if internal?(controller)
133
+
134
+ path = join(mount_prefix, spec)
135
+
136
+ return [] unless matches_prefix?(path)
137
+ return [] unless matches_version?(controller, path)
138
+
139
+ resource = controller.split("/").last
140
+
141
+ return [] unless matches_resource?(resource, controller)
142
+
143
+ normalize_methods(rails_route.verb).map do |method|
144
+ Route.new(
145
+ name: rails_route.name,
146
+ method: method,
147
+ path: path,
148
+ controller: controller,
149
+ action: action,
150
+ resource: resource,
151
+ api_version: detect_version(controller, path),
152
+ rails_route: rails_route,
153
+ mount_prefix: mount_prefix
154
+ )
155
+ end
156
+ end
157
+
158
+ # A mounted app that carries its own route table (a Rails::Engine, or
159
+ # anything shaped like one). Checked by shape rather than by class: the
160
+ # `Rails::Engine` constant need not be loaded, and a Sinatra app — whose
161
+ # `routes` is a plain Hash — is a Rack mount, not an engine.
162
+ def engine?(app)
163
+ return false unless app.respond_to?(:rack_app)
164
+
165
+ rack_app = app.rack_app
166
+
167
+ rack_app.respond_to?(:routes) && rack_app.routes.respond_to?(:routes)
168
+ end
169
+
170
+ def skip_reason(app)
171
+ rack_app = app.respond_to?(:rack_app) ? app.rack_app : app
172
+
173
+ if defined?(ActionDispatch::Routing::Redirect) && rack_app.is_a?(ActionDispatch::Routing::Redirect)
174
+ "redirect"
175
+ else
176
+ "mount"
177
+ end
178
+ end
179
+
180
+ # Rails 8 exposes the verb as a plain string: "GET", "GET|POST" for
181
+ # `via: [:get, :post]`, "" for `via: :all`. Older versions used a regexp
182
+ # whose source carried anchors.
183
+ def normalize_methods(verb)
184
+ values = verb.to_s
185
+ .gsub(/[$^]/, "")
186
+ .split("|")
187
+ .map(&:strip)
188
+ .reject(&:empty?)
189
+ .map(&:upcase)
190
+
191
+ values.empty? ? [ANY] : values
192
+ end
193
+
194
+ def normalize_path(path)
195
+ path.sub(/\(\.:format\)\z/, "")
196
+ end
197
+
198
+ def join(mount_prefix, path)
199
+ return path if mount_prefix.nil? || mount_prefix.empty?
200
+ return mount_prefix if path == "/"
201
+
202
+ "#{mount_prefix}#{path}"
203
+ end
204
+
205
+ def internal?(controller)
206
+ controller.start_with?(
207
+ "rails/",
208
+ "active_storage/",
209
+ "action_mailbox/",
210
+ "turbo/"
211
+ )
212
+ end
213
+
214
+ def matches_prefix?(path)
215
+ return true if prefix.nil? || prefix.empty?
216
+
217
+ path.start_with?(prefix)
218
+ end
219
+
220
+ # `RESOURCE=customers`, `RESOURCE=cart` (a singular resource is served by
221
+ # `carts`) and `RESOURCE=api/v2/customers` all name the same thing.
222
+ def matches_resource?(resource, controller)
223
+ return true if resources.empty?
224
+
225
+ candidates = [resource, controller, Support.singularize(resource)]
226
+
227
+ resources.any? { |wanted| candidates.include?(wanted) }
228
+ end
229
+
230
+ def matches_version?(controller, path)
231
+ return true if version.nil? || version.empty?
232
+
233
+ controller.split("/").include?(version) ||
234
+ path.split("/").include?(version)
235
+ end
236
+
237
+ def detect_version(controller, path)
238
+ controller.split("/").find { |segment| segment.match?(/\Av\d+\z/) } ||
239
+ path.split("/").find { |segment| segment.match?(/\Av\d+\z/) }
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Sanitizers
5
+ # Works on a raw captured exchange, before anything reaches the dataset:
6
+ # credentials must never be written to a generated file.
7
+ class Sanitizer
8
+ DEFAULT_SECRET_HEADERS = %w[
9
+ Authorization
10
+ Proxy-Authorization
11
+ Cookie
12
+ Set-Cookie
13
+ X-Api-Key
14
+ X-Auth-Token
15
+ X-Csrf-Token
16
+ ].freeze
17
+
18
+ SCHEME_PATTERN = /\A(Bearer|Token|Basic)\s+/i
19
+
20
+ def self.call(exchange, configuration:)
21
+ new(
22
+ exchange,
23
+ configuration: configuration
24
+ ).call
25
+ end
26
+
27
+ def initialize(exchange, configuration:)
28
+ @exchange = deep_dup(exchange)
29
+ @configuration = configuration
30
+ end
31
+
32
+ def call
33
+ sanitize_headers!(
34
+ exchange.dig("request", "headers")
35
+ )
36
+
37
+ sanitize_headers!(
38
+ exchange.dig("response", "headers"),
39
+ response: true
40
+ )
41
+
42
+ sanitize_body!("request")
43
+ sanitize_body!("response")
44
+
45
+ exchange
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :exchange, :configuration
51
+
52
+ def sanitize_headers!(headers, response: false)
53
+ return unless headers.is_a?(Hash)
54
+
55
+ replacements = configuration.sanitized_headers
56
+
57
+ headers.keys.each do |key|
58
+ if configuration.noisy_header?(key)
59
+ headers.delete(key)
60
+ next
61
+ end
62
+
63
+ if blank?(headers[key])
64
+ headers.delete(key)
65
+ next
66
+ end
67
+
68
+ replacement = find_replacement(replacements, key)
69
+
70
+ if replacement
71
+ headers[key] = replacement
72
+ next
73
+ end
74
+
75
+ headers[key] = redact(key, headers[key]) if secret_header?(key)
76
+ end
77
+ end
78
+
79
+ def sanitize_body!(side)
80
+ body = exchange.dig(side, "body")
81
+
82
+ return unless body.is_a?(Hash) || body.is_a?(Array)
83
+
84
+ exchange[side]["body"] = sanitize_value(body)
85
+ end
86
+
87
+ def sanitize_value(value)
88
+ case value
89
+ when Hash
90
+ value.each_with_object({}) do |(key, nested), result|
91
+ replacement = body_replacement(key)
92
+
93
+ result[key] = replacement || sanitize_value(nested)
94
+ end
95
+ when Array
96
+ value.map { |item| sanitize_value(item) }
97
+ else
98
+ value
99
+ end
100
+ end
101
+
102
+ def body_replacement(key)
103
+ configuration.sanitized_body_keys.find do |name, _value|
104
+ name.to_s.casecmp?(key.to_s)
105
+ end&.last
106
+ end
107
+
108
+ def find_replacement(replacements, key)
109
+ pair = replacements.find do |header, _value|
110
+ header.to_s.casecmp?(key.to_s)
111
+ end
112
+
113
+ pair&.last
114
+ end
115
+
116
+ # The scheme is kept so the generated cURL stays copy-pasteable.
117
+ def redact(key, value)
118
+ placeholder = "{{#{key.to_s.tr('-', '_').downcase}}}"
119
+
120
+ match = value.to_s.match(SCHEME_PATTERN)
121
+
122
+ match ? "#{match[1]} #{placeholder}" : placeholder
123
+ end
124
+
125
+ def secret_header?(key)
126
+ DEFAULT_SECRET_HEADERS.any? do |header|
127
+ header.casecmp?(key.to_s)
128
+ end
129
+ end
130
+
131
+ def blank?(value)
132
+ value.nil? || value.to_s.empty?
133
+ end
134
+
135
+ def deep_dup(value)
136
+ Marshal.load(Marshal.dump(value))
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ # Describes what an endpoint accepts, inferred from the requests that
5
+ # actually worked. One test sends status "active", another "passive", a third
6
+ # sends "inactive" and gets a 422: the documentation should say the field
7
+ # takes "active" | "passive", not list three requests.
8
+ class Schema
9
+ # Beyond this many distinct values a field is an open set, not a choice.
10
+ ENUM_LIMIT = 6
11
+
12
+ Field = Struct.new(:path, :types, :values, :present_count, :total_count, :repetition, keyword_init: true) do
13
+ def type
14
+ types.to_a.sort.join(" | ")
15
+ end
16
+
17
+ # A field missing from a working request cannot be required.
18
+ def required?
19
+ present_count == total_count
20
+ end
21
+
22
+ # Telling a closed set from free text: either a value came back in more
23
+ # than one request, or every value reads like a token ("active"), not
24
+ # like content ("Ada Lovelace", "ada@example.com").
25
+ def enum?
26
+ return false unless values.size.between?(2, ENUM_LIMIT)
27
+ return false unless values.all? { |value| scalar?(value) }
28
+ return false if identifier?
29
+
30
+ # Repetition only counts among the requests that carried the field:
31
+ # two requests, two different values, is not a set.
32
+ return true if repetition && values.size < present_count
33
+
34
+ values.all? { |value| token?(value) }
35
+ end
36
+
37
+ def example
38
+ values.first
39
+ end
40
+
41
+ # Test order is not stable, so a listed set must be ordered by something
42
+ # that is.
43
+ def listed_values
44
+ values.sort_by { |value| [value.to_s, value.class.name] }
45
+ end
46
+
47
+ def to_h
48
+ {
49
+ path: path,
50
+ type: type,
51
+ required: required?,
52
+ values: listed_values
53
+ }
54
+ end
55
+
56
+ private
57
+
58
+ def scalar?(value)
59
+ value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false
60
+ end
61
+
62
+ # Every record has a different one, so listing the ones a test happened
63
+ # to use would read as if those were the only allowed values.
64
+ def identifier?
65
+ path.to_s.split(/[.\[\]]/).last.to_s.match?(/\A(id|uuid|.*_id|.*_uuid|.*_key|token|slug)\z/i)
66
+ end
67
+
68
+ # A word, not content: "active", "pending_review", "USD". Digits and
69
+ # dashes usually mean a generated value ("e-00056197", "safari-859f04").
70
+ def token?(value)
71
+ case value
72
+ when true, false then true
73
+ when String then value.match?(/\A[a-z][a-z_]*\z/) || value.match?(/\A[A-Z_]{2,}\z/)
74
+ else false
75
+ end
76
+ end
77
+ end
78
+
79
+ # `payloads` are request bodies, query hashes or path parameter hashes from
80
+ # successful captures. With `repetition: true` a value seen in more than
81
+ # one payload marks a closed set — right for what tests *send*, wrong for
82
+ # what an application *returns*, where fixtures repeat by nature; response
83
+ # bodies are inferred with `repetition: false`, so only token-like values
84
+ # ("open", "done") are listed as a set.
85
+ def self.infer(payloads, repetition: true)
86
+ new(payloads, repetition: repetition).call
87
+ end
88
+
89
+ # Empty payloads are kept: a request that was accepted without any
90
+ # parameters is the proof that every parameter is optional.
91
+ def initialize(payloads, repetition: true)
92
+ @payloads = Array(payloads)
93
+ @repetition = repetition
94
+ end
95
+
96
+ def call
97
+ return self.class.empty if @payloads.empty?
98
+
99
+ fields = {}
100
+
101
+ @payloads.each do |payload|
102
+ flatten(payload).each do |path, value|
103
+ field = fields[path] ||= Field.new(
104
+ path: path,
105
+ types: [],
106
+ values: [],
107
+ present_count: 0,
108
+ total_count: @payloads.size,
109
+ repetition: @repetition
110
+ )
111
+
112
+ field.types |= [type_of(value)]
113
+ field.values |= [value] unless value.nil? || value.is_a?(Hash) || value.is_a?(Array)
114
+ field.present_count += 1
115
+ end
116
+ end
117
+
118
+ Result.new(fields.values)
119
+ end
120
+
121
+ def self.empty
122
+ Result.new([])
123
+ end
124
+
125
+ # The inferred description of one payload shape.
126
+ class Result
127
+ include Enumerable
128
+
129
+ attr_reader :fields
130
+
131
+ def initialize(fields)
132
+ @fields = fields
133
+ end
134
+
135
+ def each(&block)
136
+ fields.each(&block)
137
+ end
138
+
139
+ def empty?
140
+ fields.empty?
141
+ end
142
+
143
+ def size
144
+ fields.size
145
+ end
146
+
147
+ def [](path)
148
+ fields.find { |field| field.path == path }
149
+ end
150
+
151
+ def to_a
152
+ fields.map(&:to_h)
153
+ end
154
+ end
155
+
156
+ private
157
+
158
+ # { "customer" => { "tags" => ["a"] } } -> { "customer.tags[]" => "a" }
159
+ def flatten(value, prefix = nil, result = {})
160
+ case value
161
+ when Hash
162
+ value.each { |key, nested| flatten(nested, prefix ? "#{prefix}.#{key}" : key.to_s, result) }
163
+ when Array
164
+ # An empty array still tells the reader the field is a list.
165
+ result[prefix] = [] if value.empty? && prefix
166
+ value.each { |item| flatten(item, "#{prefix}[]", result) }
167
+ else
168
+ result[prefix] = value if prefix
169
+ end
170
+
171
+ result
172
+ end
173
+
174
+ def type_of(value)
175
+ case value
176
+ when String then "string"
177
+ when Integer then "integer"
178
+ when Float then "number"
179
+ when true, false then "boolean"
180
+ when nil then "null"
181
+ when Array then "array"
182
+ when Hash then "object"
183
+ else value.class.name.downcase
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ # Minimal inflection helpers. ActiveSupport is used when it is available so
5
+ # that generated titles match the host application's inflections.
6
+ module Support
7
+ module_function
8
+
9
+ def underscore(value)
10
+ string = value.to_s.dup
11
+ return ActiveSupport::Inflector.underscore(string) if defined?(ActiveSupport::Inflector)
12
+
13
+ string.gsub("::", "/")
14
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
15
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
16
+ .tr("-", "_")
17
+ .downcase
18
+ end
19
+
20
+ def humanize(value)
21
+ underscore(value).tr("_", " ").strip
22
+ end
23
+
24
+ def titleize(value)
25
+ humanize(value).split(/\s+/).map { |word| word.empty? ? word : word[0].upcase + word[1..] }.join(" ")
26
+ end
27
+
28
+ def singularize(value)
29
+ string = value.to_s
30
+ return ActiveSupport::Inflector.singularize(string) if defined?(ActiveSupport::Inflector)
31
+
32
+ case string
33
+ when /ies\z/i then string.sub(/ies\z/i, "y")
34
+ when /(ss|sh|ch|x|z)es\z/i then string.sub(/es\z/i, "")
35
+ when /ss\z/i then string
36
+ when /s\z/i then string.sub(/s\z/i, "")
37
+ else string
38
+ end
39
+ end
40
+
41
+ def pluralize(value)
42
+ string = value.to_s
43
+ return ActiveSupport::Inflector.pluralize(string) if defined?(ActiveSupport::Inflector)
44
+
45
+ case string
46
+ when /(ss|sh|ch|x|z)\z/i then "#{string}es"
47
+ when /[^aeiou]y\z/i then string.sub(/y\z/i, "ies")
48
+ # Already plural, as resource names usually are.
49
+ when /s\z/i then string
50
+ else "#{string}s"
51
+ end
52
+ end
53
+
54
+ def parameterize(value)
55
+ underscore(value).gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ VERSION = "0.1.1"
5
+ end
data/lib/reqcord.rb ADDED
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "time"
6
+ require "uri"
7
+ require "rack/utils"
8
+ require "fileutils"
9
+ require "pathname"
10
+
11
+ require_relative "reqcord/version"
12
+ require_relative "reqcord/errors"
13
+ require_relative "reqcord/support"
14
+ require_relative "reqcord/configuration"
15
+
16
+ require_relative "reqcord/request_example"
17
+ require_relative "reqcord/response_example"
18
+ require_relative "reqcord/schema"
19
+ require_relative "reqcord/endpoint"
20
+ require_relative "reqcord/dataset"
21
+
22
+ require_relative "reqcord/route_collector"
23
+
24
+ require_relative "reqcord/capture"
25
+ require_relative "reqcord/capture/test_context"
26
+ require_relative "reqcord/capture/collector"
27
+
28
+ require_relative "reqcord/sanitizers/sanitizer"
29
+ require_relative "reqcord/renderers/payload"
30
+ require_relative "reqcord/renderers/curl"
31
+ require_relative "reqcord/exporters"
32
+ require_relative "reqcord/exporters/curl"
33
+ require_relative "reqcord/exporters/markdown"
34
+ require_relative "reqcord/exporters/postman"
35
+ require_relative "reqcord/generator"
36
+
37
+ require_relative "reqcord/railtie" if defined?(Rails::Railtie)
38
+
39
+ module Reqcord
40
+ class << self
41
+ attr_writer :root
42
+
43
+ def root
44
+ return @root if @root
45
+
46
+ if defined?(Rails) &&
47
+ Rails.respond_to?(:root) &&
48
+ Rails.root
49
+
50
+ Rails.root
51
+ else
52
+ Pathname(Dir.pwd)
53
+ end
54
+ end
55
+
56
+ def configuration
57
+ @configuration ||=
58
+ Configuration.load(
59
+ root: root
60
+ )
61
+ end
62
+
63
+ def reload_configuration!
64
+ @configuration =
65
+ Configuration.load(
66
+ root: root
67
+ )
68
+ end
69
+
70
+ def log(message)
71
+ $stdout.puts("[reqcord] #{message}")
72
+ end
73
+
74
+ def warn(message)
75
+ $stderr.puts("[reqcord] #{message}")
76
+ end
77
+ end
78
+ end