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,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Exporters
5
+ class Markdown
6
+ def self.call(dataset:, output_dir:, configuration:)
7
+ new(
8
+ dataset: dataset,
9
+ output_dir: output_dir,
10
+ configuration: configuration
11
+ ).call
12
+ end
13
+
14
+ def initialize(dataset:, output_dir:, configuration:)
15
+ @dataset = dataset
16
+ @output_dir = Pathname(output_dir)
17
+ @configuration = configuration
18
+ end
19
+
20
+ # Returns every written path.
21
+ def call
22
+ FileUtils.mkdir_p(output_dir)
23
+
24
+ written = [write(output_dir.join("README.md"), index_document)]
25
+
26
+ written_resources.each do |resource|
27
+ directory = output_dir.join(resource.slug)
28
+
29
+ FileUtils.mkdir_p(directory)
30
+
31
+ written << write(
32
+ directory.join("index.md"),
33
+ resource_document(resource)
34
+ )
35
+
36
+ filenames(resource).each do |endpoint, filename|
37
+ next unless documented?(endpoint)
38
+
39
+ written << write(
40
+ directory.join(filename),
41
+ endpoint_document(resource, endpoint)
42
+ )
43
+ end
44
+ end
45
+
46
+ written
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :dataset, :output_dir, :configuration
52
+
53
+ # An endpoint gets a page when a test reached it, or when the project
54
+ # asked for the gaps to be written out too.
55
+ def documented?(endpoint)
56
+ endpoint.curl_ready? || configuration.include_uncovered?
57
+ end
58
+
59
+ # A resource with nothing to show gets no directory, so the index never
60
+ # links to a page that was not written.
61
+ def written_resources
62
+ @written_resources ||= dataset.resources.select do |resource|
63
+ resource.endpoints.any? { |endpoint| documented?(endpoint) }
64
+ end
65
+ end
66
+
67
+ def written?(resource)
68
+ written_resources.include?(resource)
69
+ end
70
+
71
+ def write(path, content)
72
+ File.write(path, content)
73
+
74
+ path.to_s
75
+ end
76
+
77
+ def filenames(resource)
78
+ resource.file_basenames.transform_values { |basename| "#{basename}.md" }
79
+ end
80
+
81
+ def index_document
82
+ lines = ["# API Documentation", ""]
83
+ lines << "Generated by Reqcord from the application's integration tests."
84
+ lines << ""
85
+ lines << "Base URL: `#{configuration.base_url}`"
86
+ lines << ""
87
+ lines.concat(placeholders_section)
88
+
89
+ dataset.resources.each do |resource|
90
+ lines << if written?(resource)
91
+ "## [#{resource.title}](#{resource.slug}/index.md)"
92
+ else
93
+ "## #{resource.title}"
94
+ end
95
+ lines << ""
96
+ lines.concat(namespace_line(resource))
97
+ lines.concat(endpoint_table(resource, prefix: "#{resource.slug}/"))
98
+ lines << ""
99
+ end
100
+
101
+ undocumented = dataset.endpoints.reject(&:curl_ready?)
102
+
103
+ unless undocumented.empty?
104
+ lines << "## No Successful Request Captured"
105
+ lines << ""
106
+ lines << "No successful 2xx request was captured for these routes:"
107
+ lines << ""
108
+ undocumented.each { |endpoint| lines << "- `#{endpoint.method} #{endpoint.path}`" }
109
+ lines << ""
110
+ end
111
+
112
+ lines.join("\n")
113
+ end
114
+
115
+ # Sanitized values are written as placeholders; the reader has to know
116
+ # what to substitute.
117
+ def placeholders_section
118
+ placeholders = configuration.sanitized_headers.values.grep(/\{\{.+\}\}/)
119
+
120
+ return [] if placeholders.empty?
121
+
122
+ rows = configuration.sanitized_headers.filter_map do |header, value|
123
+ "| #{header} | `#{value}` |" if value.to_s.match?(/\{\{.+\}\}/)
124
+ end
125
+
126
+ ["## Placeholders", "", "| Header | Replace with |", "| --- | --- |", *rows, ""]
127
+ end
128
+
129
+ # Two resources can share a title (admin/customers, api/v2/customers);
130
+ # the namespace tells them apart.
131
+ def namespace_line(resource)
132
+ return [] if resource.namespace.empty?
133
+
134
+ ["Namespace: `#{resource.namespace}`", ""]
135
+ end
136
+
137
+ def resource_document(resource)
138
+ lines = ["# #{resource.title}", ""]
139
+ lines.concat(namespace_line(resource))
140
+
141
+ versions = resource.api_versions
142
+
143
+ unless versions.empty?
144
+ lines << "API version: #{versions.map { |version| "`#{version}`" }.join(', ')}"
145
+ lines << ""
146
+ end
147
+
148
+ lines.concat(endpoint_table(resource))
149
+ lines << ""
150
+ lines.join("\n")
151
+ end
152
+
153
+ def endpoint_table(resource, prefix: "")
154
+ rows = filenames(resource).map do |endpoint, filename|
155
+ path =
156
+ if documented?(endpoint)
157
+ "[`#{endpoint.path}`](#{prefix}#{filename})"
158
+ else
159
+ "`#{endpoint.path}`"
160
+ end
161
+
162
+ covered = endpoint.curl_ready? ? "" : " _(no successful request captured)_"
163
+
164
+ "| `#{endpoint.method}` | #{path} | #{endpoint.name}#{covered} |"
165
+ end
166
+
167
+ ["| Method | Path | Description |", "| --- | --- | --- |", *rows]
168
+ end
169
+
170
+ def endpoint_document(resource, endpoint)
171
+ example = endpoint.primary_request_example
172
+
173
+ lines = ["# #{endpoint.name}", "", endpoint_line(endpoint), ""]
174
+ lines.concat(namespace_line(resource))
175
+
176
+ unless endpoint.curl_ready?
177
+ lines << "_No successful 2xx request was captured for this endpoint yet._"
178
+ lines << ""
179
+ end
180
+
181
+ lines.concat(headers_section(example))
182
+ lines.concat(schema_section("Path Parameters", endpoint.path_param_schema))
183
+ lines.concat(schema_section("Query Parameters", endpoint.query_schema))
184
+ lines.concat(schema_section("Body Parameters", endpoint.body_schema))
185
+ lines.concat(request_body_section(example))
186
+ lines.concat(curl_section(example))
187
+ lines.concat(responses_section(endpoint))
188
+ lines.concat(["---", "", "Resource: [#{resource.title}](index.md)", ""])
189
+
190
+ lines.join("\n")
191
+ end
192
+
193
+ # Field, type, whether every accepted request carried it, and the values
194
+ # those requests used. Values are listed only when they look like a
195
+ # closed set; otherwise one of them stands as an example.
196
+ def schema_section(title, schema, level: 2)
197
+ return [] if schema.empty?
198
+
199
+ rows = schema.map do |field|
200
+ "| `#{field.path}` | #{field.type} | #{field.required? ? 'yes' : 'no'} | #{values_cell(field)} |"
201
+ end
202
+
203
+ ["#{'#' * level} #{title}", "", "| Field | Type | Required | Values |", "| --- | --- | --- | --- |", *rows, ""]
204
+ end
205
+
206
+ def values_cell(field)
207
+ return "-" if field.values.empty?
208
+ return field.listed_values.map { |value| "`#{value.inspect}`" }.join(" \\| ") if field.enum?
209
+
210
+ "`#{field.example.inspect}`"
211
+ end
212
+
213
+ # `PATCH /api/cart` (also `PUT`)
214
+ def endpoint_line(endpoint)
215
+ line = "`#{endpoint.method} #{endpoint.path}`"
216
+
217
+ return line if endpoint.also_methods.empty?
218
+
219
+ "#{line} (also #{endpoint.also_methods.map { |method| "`#{method}`" }.join(', ')})"
220
+ end
221
+
222
+ def headers_section(example)
223
+ return [] if example.nil? || example.headers.empty?
224
+
225
+ rows = example.headers.map { |name, value| "| #{name} | `#{value}` |" }
226
+
227
+ ["## Headers", "", "| Header | Value |", "| --- | --- |", *rows, ""]
228
+ end
229
+
230
+ # A form body is shown as the key/value structure the test built, which
231
+ # reads better than the encoded string; the note says how it travels.
232
+ def request_body_section(example)
233
+ return [] unless example&.body?
234
+
235
+ lines = ["## Example Request", ""]
236
+
237
+ unless Renderers::Payload.json?(example) || example.content_type.to_s.empty?
238
+ lines << "Sent as `#{example.content_type}`; the cURL below carries it in that encoding."
239
+ lines << ""
240
+ end
241
+
242
+ lines.concat([*code_block(example.body, example.content_type), ""])
243
+ end
244
+
245
+ def curl_section(example)
246
+ return [] if example.nil?
247
+
248
+ ["## cURL", "", "```bash", curl_for(example), "```", ""]
249
+ end
250
+
251
+ def responses_section(endpoint)
252
+ return [] if endpoint.response_examples.empty?
253
+
254
+ lines = ["## Responses", ""]
255
+
256
+ endpoint.responses.each do |response|
257
+ lines << "### #{response.example.title}"
258
+ lines << ""
259
+ lines.concat(schema_section("Fields", response.schema, level: 4))
260
+ lines.concat(response_body(response.example))
261
+ end
262
+
263
+ lines
264
+ end
265
+
266
+ def response_body(example)
267
+ return ["_No response body._", ""] unless example.body?
268
+
269
+ [*code_block(example.body, example.content_type), ""]
270
+ end
271
+
272
+ def curl_for(example)
273
+ Renderers::Curl.call(
274
+ example,
275
+ base_url: configuration.base_url
276
+ )
277
+ end
278
+
279
+ def code_block(value, content_type)
280
+ case value
281
+ when Hash, Array then ["```json", pretty_json(value), "```"]
282
+ else ["```#{content_type.to_s.include?('json') ? 'json' : 'text'}", value.to_s, "```"]
283
+ end
284
+ end
285
+
286
+ def pretty_json(value)
287
+ JSON.pretty_generate(value)
288
+ rescue JSON::GeneratorError
289
+ value.to_s
290
+ end
291
+ end
292
+
293
+ register("markdown", Markdown)
294
+ end
295
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ module Exporters
5
+ # Writes a Postman Collection v2.1 (`postman/collection.json`). Folders
6
+ # follow the controller namespaces, each documented endpoint is one request
7
+ # built from the successful captured example, and every captured status
8
+ # is saved as a response example. Hoppscotch imports the same file.
9
+ class Postman
10
+ SCHEMA_URL = "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
11
+ PLACEHOLDER = /\{\{([A-Za-z0-9_]+)\}\}/
12
+ BEARER = /\ABearer\s+\{\{([A-Za-z0-9_]+)\}\}\z/i
13
+
14
+ def self.call(dataset:, output_dir:, configuration:)
15
+ new(
16
+ dataset: dataset,
17
+ output_dir: output_dir,
18
+ configuration: configuration
19
+ ).call
20
+ end
21
+
22
+ def initialize(dataset:, output_dir:, configuration:)
23
+ @dataset = dataset
24
+ @output_dir = Pathname(output_dir).join("postman")
25
+ @configuration = configuration
26
+ end
27
+
28
+ def call
29
+ FileUtils.mkdir_p(output_dir)
30
+
31
+ path = output_dir.join("collection.json")
32
+ File.write(path, "#{JSON.pretty_generate(collection)}\n")
33
+
34
+ [path.to_s]
35
+ end
36
+
37
+ def collection
38
+ {
39
+ info: {
40
+ name: "#{File.basename(configuration.root.to_s)} API",
41
+ description: "Generated by Reqcord from the application's integration tests.",
42
+ schema: SCHEMA_URL
43
+ },
44
+ item: folders,
45
+ variable: variables,
46
+ auth: auth
47
+ }.compact
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :dataset, :output_dir, :configuration
53
+
54
+ # api/v2/customers -> Api > V2 > Customers, requests in the leaf folder.
55
+ def folders
56
+ tree = { items: [], children: {} }
57
+
58
+ dataset.resources.each do |resource|
59
+ requests = resource.endpoints.select(&:curl_ready?).map { |endpoint| request_item(endpoint) }
60
+ next if requests.empty?
61
+
62
+ leaf = resource.segments.reduce(tree) do |node, segment|
63
+ node[:children][segment] ||= { items: [], children: {} }
64
+ end
65
+
66
+ leaf[:items].concat(requests)
67
+ end
68
+
69
+ render_folders(tree)
70
+ end
71
+
72
+ def render_folders(node)
73
+ node[:children].map do |segment, child|
74
+ { name: Support.titleize(segment), item: render_folders(child) + child[:items] }
75
+ end
76
+ end
77
+
78
+ def request_item(endpoint)
79
+ {
80
+ name: endpoint.name,
81
+ request: request_object(endpoint.primary_request_example, endpoint),
82
+ response: endpoint.responses.map { |response| response_object(response, endpoint) }
83
+ }
84
+ end
85
+
86
+ def request_object(example, endpoint)
87
+ {
88
+ method: example.method,
89
+ description: description(endpoint),
90
+ header: headers(example),
91
+ url: url_object(example),
92
+ body: body_object(example),
93
+ auth: request_auth(example)
94
+ }.compact
95
+ end
96
+
97
+ def description(endpoint)
98
+ lines = ["#{endpoint.method} #{endpoint.path}", "#{endpoint.controller}##{endpoint.action}"]
99
+ lines << "Also answers #{endpoint.also_methods.join(', ')}." unless endpoint.also_methods.empty?
100
+ lines << "Route: #{endpoint.route_name}" if endpoint.route_name
101
+
102
+ lines.join("\n")
103
+ end
104
+
105
+ # The bearer credential lives on the collection; a request that carried
106
+ # it inherits, one that did not says so, so public endpoints replay
107
+ # exactly as their tests did.
108
+ def headers(example)
109
+ example.headers.filter_map do |key, value|
110
+ next if auth && key.to_s.casecmp?("Authorization")
111
+
112
+ { key: key.to_s, value: value.to_s }
113
+ end
114
+ end
115
+
116
+ def request_auth(example)
117
+ return nil unless auth
118
+ return nil if example.headers.any? { |key, _| key.to_s.casecmp?("Authorization") }
119
+
120
+ { type: "noauth" }
121
+ end
122
+
123
+ def url_object(example)
124
+ url = {
125
+ raw: "{{base_url}}#{Renderers::Payload.path_with_query(example)}",
126
+ host: ["{{base_url}}"],
127
+ path: example.path.split("/").reject(&:empty?),
128
+ query: Renderers::Payload.query_pairs(example).map { |key, value| { key: key, value: value.to_s } }
129
+ }
130
+
131
+ url.delete(:query) if url[:query].empty?
132
+ url
133
+ end
134
+
135
+ def body_object(example)
136
+ return nil unless example.body?
137
+
138
+ if Renderers::Payload.json?(example)
139
+ { mode: "raw", raw: Renderers::Payload.raw_body(example), options: { raw: { language: "json" } } }
140
+ elsif example.body.is_a?(Hash)
141
+ pairs = Renderers::Payload.form_pairs(example).map { |key, value| { key: key, value: value.to_s, type: "text" } }
142
+
143
+ { mode: "urlencoded", urlencoded: pairs }
144
+ else
145
+ { mode: "raw", raw: example.body.to_s }
146
+ end
147
+ end
148
+
149
+ def response_object(response, endpoint)
150
+ example = response.example
151
+ request = endpoint.request_examples.find { |candidate| candidate.response_status == response.status } ||
152
+ endpoint.primary_request_example
153
+
154
+ {
155
+ name: example.title,
156
+ originalRequest: request_object(request, endpoint),
157
+ status: example.status_text,
158
+ code: response.status,
159
+ _postman_previewlanguage: example.content_type.to_s.include?("json") ? "json" : "text",
160
+ header: example.headers.map { |key, value| { key: key.to_s, value: value.to_s } },
161
+ body: response_body(example)
162
+ }
163
+ end
164
+
165
+ def response_body(example)
166
+ return "" unless example.body?
167
+ return example.body if example.body.is_a?(String)
168
+
169
+ JSON.pretty_generate(example.body)
170
+ end
171
+
172
+ # `base_url`, plus every `{{name}}` the sanitizer wrote into the
173
+ # documented examples, so the collection declares what it expects.
174
+ def variables
175
+ names = documented_examples.flat_map do |example|
176
+ [example.headers, example.body, example.query_params].flat_map do |value|
177
+ JSON.generate(value).scan(PLACEHOLDER).flatten
178
+ end
179
+ end
180
+
181
+ names |= (configuration.sanitized_headers.values + configuration.sanitized_body_keys.values)
182
+ .flat_map { |value| value.to_s.scan(PLACEHOLDER).flatten }
183
+
184
+ [{ key: "base_url", value: configuration.base_url, type: "string" }] +
185
+ (names - ["base_url"]).uniq.sort.map { |name| { key: name, value: "", type: "string" } }
186
+ end
187
+
188
+ def auth
189
+ return @auth if defined?(@auth)
190
+
191
+ placeholder = documented_examples.filter_map do |example|
192
+ value = example.headers.find { |key, _| key.to_s.casecmp?("Authorization") }&.last
193
+ value.to_s[BEARER, 1]
194
+ end.first
195
+
196
+ @auth = placeholder && { type: "bearer", bearer: [{ key: "token", value: "{{#{placeholder}}}", type: "string" }] }
197
+ end
198
+
199
+ def documented_examples
200
+ @documented_examples ||= dataset.curl_ready_endpoints.flat_map(&:request_examples)
201
+ end
202
+ end
203
+
204
+ register("postman", Postman)
205
+ end
206
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reqcord
4
+ # Exporters register themselves here, so adding one means adding a file
5
+ # rather than editing the pipeline.
6
+ module Exporters
7
+ class << self
8
+ def register(name, exporter)
9
+ registry[name.to_s] = exporter
10
+ end
11
+
12
+ def fetch(name)
13
+ registry.fetch(name.to_s) do
14
+ raise ConfigurationError,
15
+ "unknown exporter #{name.inspect}, expected one of #{names.join(', ')}"
16
+ end
17
+ end
18
+
19
+ def registered?(name)
20
+ registry.key?(name.to_s)
21
+ end
22
+
23
+ def names
24
+ registry.keys.sort
25
+ end
26
+
27
+ def registry
28
+ @registry ||= {}
29
+ end
30
+ end
31
+ end
32
+ end