generator.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. #!/usr/bin/env python2.7
  2. from __future__ import absolute_import
  3. import click
  4. import docker
  5. import json
  6. import os
  7. import six
  8. import zlib
  9. from datetime import datetime
  10. from contextlib import contextmanager
  11. from sentry.runner.commands.devservices import get_docker_client, get_or_create
  12. from sentry.utils.apidocs import MockUtils, iter_scenarios, iter_endpoints, get_sections
  13. from sentry.utils.integrationdocs import sync_docs
  14. from sentry.conf.server import SENTRY_DEVSERVICES
  15. from subprocess import Popen
  16. HERE = os.path.abspath(os.path.dirname(__file__))
  17. OUTPUT_PATH = "/usr/src/output"
  18. SENTRY_CONFIG = os.environ["SENTRY_CONF"] = os.path.join(HERE, "sentry.conf.py")
  19. os.environ["SENTRY_SKIP_BACKEND_VALIDATION"] = "1"
  20. client = get_docker_client()
  21. # Use a unique network and namespace for our apidocs
  22. namespace = "apidocs"
  23. # Define our set of containers we want to run
  24. APIDOC_CONTAINERS = ["postgres", "redis", "clickhouse", "snuba", "relay"]
  25. devservices_settings = {
  26. container_name: SENTRY_DEVSERVICES[container_name] for container_name in APIDOC_CONTAINERS
  27. }
  28. apidoc_containers_overrides = {
  29. "postgres": {"environment": {"POSTGRES_DB": "sentry_api_docs"}, "volumes": None},
  30. "redis": {"volumes": None},
  31. "clickhouse": {"ports": None, "volumes": None, "only_if": None},
  32. "snuba": {
  33. "pull": None,
  34. "command": ["devserver", "--no-workers"],
  35. "environment": {
  36. "CLICKHOUSE_HOST": namespace + "_clickhouse",
  37. "DEFAULT_BROKERS": None,
  38. "REDIS_HOST": namespace + "_redis",
  39. },
  40. "volumes": None,
  41. "only_if": None,
  42. },
  43. "relay": {"pull": None, "volumes": None, "only_if": None, "with_devserver": None},
  44. }
  45. def deep_merge(defaults, overrides):
  46. """
  47. Deep merges two dictionaries.
  48. If the value is None in `overrides`, that key-value pair will not show up in the final result.
  49. """
  50. merged = {}
  51. for key in defaults:
  52. if isinstance(defaults[key], dict):
  53. if key not in overrides:
  54. merged[key] = defaults[key]
  55. elif overrides[key] is None:
  56. continue
  57. elif isinstance(overrides[key], dict):
  58. merged[key] = deep_merge(defaults[key], overrides[key])
  59. else:
  60. raise Exception("Types must match")
  61. elif key in overrides and overrides[key] is None:
  62. continue
  63. elif key in overrides:
  64. merged[key] = overrides[key]
  65. else:
  66. merged[key] = defaults[key]
  67. return merged
  68. @contextmanager
  69. def apidoc_containers():
  70. network = get_or_create(client, "network", namespace)
  71. containers = deep_merge(devservices_settings, apidoc_containers_overrides)
  72. # Massage our list into some shared settings instead of repeating
  73. # it for each definition.
  74. for name, options in containers.items():
  75. options["network"] = namespace
  76. options["detach"] = True
  77. options["name"] = namespace + "_" + name
  78. containers[name] = options
  79. # Pull all of our unique images once.
  80. pulled = set()
  81. for name, options in containers.items():
  82. if options["image"] not in pulled:
  83. click.secho("> Pulling image '%s'" % options["image"], err=True, fg="green")
  84. client.images.pull(options["image"])
  85. pulled.add(options["image"])
  86. # Run each of our containers, if found running already, delete first
  87. # and create new. We never want to reuse.
  88. for name, options in containers.items():
  89. try:
  90. container = client.containers.get(options["name"])
  91. except docker.errors.NotFound:
  92. pass
  93. else:
  94. container.stop()
  95. container.remove()
  96. click.secho("> Creating '%s' container" % options["name"], err=True, fg="yellow")
  97. client.containers.run(**options)
  98. yield
  99. # Delete all of our containers now. If it's not running, do nothing.
  100. for name, options in containers.items():
  101. try:
  102. container = client.containers.get(options["name"])
  103. except docker.errors.NotFound:
  104. pass
  105. else:
  106. click.secho("> Removing '%s' container" % container.name, err=True, fg="red")
  107. container.stop()
  108. container.remove()
  109. # Remove our network that we created.
  110. click.secho("> Removing '%s' network" % network.name, err=True, fg="red")
  111. network.remove()
  112. def color_for_string(s):
  113. colors = ("red", "green", "yellow", "blue", "cyan", "magenta")
  114. return colors[zlib.crc32(s) % len(colors)]
  115. def report(category, message, fg=None):
  116. if fg is None:
  117. fg = color_for_string(category)
  118. click.echo(
  119. "[%s] %s: %s"
  120. % (six.text_type(datetime.utcnow()).split(".")[0], click.style(category, fg=fg), message)
  121. )
  122. def run_scenario(vars, scenario_ident, func):
  123. from sentry.utils.apidocs import Runner
  124. runner = Runner(scenario_ident, func, **vars)
  125. report("scenario", 'Running scenario "%s"' % scenario_ident)
  126. func(runner)
  127. return runner.to_json()
  128. @click.command()
  129. @click.option("--output-path", type=click.Path())
  130. @click.option("--output-format", type=click.Choice(["json", "markdown", "both"]), default="both")
  131. def cli(output_path, output_format):
  132. global OUTPUT_PATH
  133. if output_path is not None:
  134. OUTPUT_PATH = os.path.abspath(output_path)
  135. with apidoc_containers():
  136. from sentry.runner import configure
  137. configure()
  138. sentry = Popen(
  139. [
  140. "sentry",
  141. "--config=" + SENTRY_CONFIG,
  142. "run",
  143. "web",
  144. "-w",
  145. "1",
  146. "--bind",
  147. "127.0.0.1:9000",
  148. ]
  149. )
  150. from django.core.management import call_command
  151. call_command(
  152. "migrate",
  153. interactive=False,
  154. traceback=True,
  155. verbosity=0,
  156. migrate=True,
  157. merge=True,
  158. ignore_ghost_migrations=True,
  159. )
  160. utils = MockUtils()
  161. report("org", "Creating user and organization")
  162. user = utils.create_user("john@interstellar.invalid")
  163. org = utils.create_org("The Interstellar Jurisdiction", owner=user)
  164. report("auth", "Creating api token")
  165. api_token = utils.create_api_token(user)
  166. report("org", "Creating team")
  167. team = utils.create_team("Powerful Abolitionist", org=org)
  168. utils.join_team(team, user)
  169. projects = []
  170. for project_name in "Pump Station", "Prime Mover":
  171. report("project", 'Creating project "%s"' % project_name)
  172. project = utils.create_project(project_name, teams=[team], org=org)
  173. release = utils.create_release(project=project, user=user)
  174. report("event", 'Creating event for "%s"' % project_name)
  175. event1 = utils.create_event(project=project, release=release, platform="python")
  176. event2 = utils.create_event(project=project, release=release, platform="java")
  177. projects.append({"project": project, "release": release, "events": [event1, event2]})
  178. # HACK: the scenario in ProjectDetailsEndpoint#put requires our integration docs to be in place
  179. # so that we can validate the platform. We create the docker container that runs generator.py
  180. # with SENTRY_LIGHT_BUILD=1, which doesn't run `sync_docs` and `sync_docs` requires sentry
  181. # to be configured, which we do in this file. So, we need to do the sync_docs here.
  182. sync_docs(quiet=True)
  183. vars = {
  184. "org": org,
  185. "me": user,
  186. "api_token": api_token,
  187. "teams": [{"team": team, "projects": projects}],
  188. }
  189. scenario_map = {}
  190. report("docs", "Collecting scenarios")
  191. for scenario_ident, func in iter_scenarios():
  192. scenario = run_scenario(vars, scenario_ident, func)
  193. scenario_map[scenario_ident] = scenario
  194. section_mapping = {}
  195. report("docs", "Collecting endpoint documentation")
  196. for endpoint in iter_endpoints():
  197. report("endpoint", 'Collecting docs for "%s"' % endpoint["endpoint_name"])
  198. section_mapping.setdefault(endpoint["section"], []).append(endpoint)
  199. sections = get_sections()
  200. if output_format in ("json", "both"):
  201. output_json(sections, scenario_map, section_mapping)
  202. if output_format in ("markdown", "both"):
  203. output_markdown(sections, scenario_map, section_mapping)
  204. if sentry is not None:
  205. report("sentry", "Shutting down sentry server")
  206. sentry.kill()
  207. sentry.wait()
  208. def output_json(sections, scenarios, section_mapping):
  209. report("docs", "Generating JSON documents")
  210. for id, scenario in scenarios.items():
  211. dump_json("scenarios/%s.json" % id, scenario)
  212. section_listings = {}
  213. for section, title in sections.items():
  214. entries = {}
  215. for endpoint in section_mapping.get(section, []):
  216. entries[endpoint["endpoint_name"]] = endpoint["title"]
  217. dump_json("endpoints/%s.json" % endpoint["endpoint_name"], endpoint)
  218. section_listings[section] = {"title": title, "entries": entries}
  219. dump_json("sections.json", {"sections": section_listings})
  220. def output_markdown(sections, scenarios, section_mapping):
  221. report("docs", "Generating markdown documents")
  222. # With nested URLs, we can have groups of URLs that are nested under multiple base URLs. We only want
  223. # them to show up once in the index.md. So, keep a set of endpoints we have already processed
  224. # to avoid duplication.
  225. processed_endpoints = set()
  226. for section, title in sections.items():
  227. i = 0
  228. links = []
  229. for endpoint in section_mapping.get(section, []):
  230. i += 1
  231. path = u"{}/{}.md".format(section, endpoint["endpoint_name"])
  232. auth = ""
  233. if len(endpoint["params"].get("auth", [])):
  234. auth = endpoint["params"]["auth"][0]["description"]
  235. payload = dict(
  236. title=endpoint["title"],
  237. sidebar_order=i,
  238. description="\n".join(endpoint["text"]).strip(),
  239. warning=endpoint["warning"],
  240. method=endpoint["method"],
  241. api_path=endpoint["path"],
  242. query_parameters=endpoint["params"].get("query"),
  243. path_parameters=endpoint["params"].get("path"),
  244. parameters=endpoint["params"].get("param"),
  245. authentication=auth,
  246. example_request=format_request(endpoint, scenarios),
  247. example_response=format_response(endpoint, scenarios),
  248. )
  249. dump_markdown(path, payload)
  250. if path not in processed_endpoints:
  251. links.append({"title": endpoint["title"], "path": path})
  252. processed_endpoints.add(path)
  253. dump_index_markdown(section, title, links)
  254. def dump_json(path, data):
  255. path = os.path.join(OUTPUT_PATH, "json", path)
  256. try:
  257. os.makedirs(os.path.dirname(path))
  258. except OSError:
  259. pass
  260. with open(path, "w") as f:
  261. for line in json.dumps(data, indent=2, sort_keys=True).splitlines():
  262. f.write(line.rstrip() + "\n")
  263. def dump_index_markdown(section, title, links):
  264. from sentry.web.helpers import render_to_string
  265. path = os.path.join(OUTPUT_PATH, "markdown", section, "index.md")
  266. try:
  267. os.makedirs(os.path.dirname(path))
  268. except OSError:
  269. pass
  270. with open(path, "w") as f:
  271. contents = render_to_string("sentry/apidocs/index.md", dict(title=title, links=links))
  272. f.write(contents)
  273. def dump_markdown(path, data):
  274. path = os.path.join(OUTPUT_PATH, "markdown", path)
  275. try:
  276. os.makedirs(os.path.dirname(path))
  277. except OSError:
  278. pass
  279. with open(path, "w") as f:
  280. template = u"""---
  281. # This file is automatically generated from the API using `sentry/api-docs/generator.py.`
  282. # Do not manually edit this file.
  283. {}
  284. ---
  285. """
  286. contents = template.format(json.dumps(data, sort_keys=True, indent=2))
  287. f.write(contents)
  288. def find_first_scenario(endpoint, scenario_map):
  289. for scene in endpoint["scenarios"]:
  290. if scene not in scenario_map:
  291. continue
  292. try:
  293. return scenario_map[scene]["requests"][0]
  294. except IndexError:
  295. return None
  296. return None
  297. def format_request(endpoint, scenario_map):
  298. scene = find_first_scenario(endpoint, scenario_map)
  299. if not scene:
  300. return ""
  301. request = scene["request"]
  302. lines = [
  303. u"{} {} HTTP/1.1".format(request["method"], request["path"]),
  304. "Host: sentry.io",
  305. "Authorization: Bearer <token>",
  306. ]
  307. lines.extend(format_headers(request["headers"]))
  308. if request["data"]:
  309. lines.append("")
  310. lines.append(json.dumps(request["data"], sort_keys=True, indent=2))
  311. return "\n".join(lines)
  312. def format_response(endpoint, scenario_map):
  313. scene = find_first_scenario(endpoint, scenario_map)
  314. if not scene:
  315. return ""
  316. response = scene["response"]
  317. lines = [u"HTTP/1.1 {} {}".format(response["status"], response["reason"])]
  318. lines.extend(format_headers(response["headers"]))
  319. if response["data"]:
  320. lines.append("")
  321. lines.append(json.dumps(response["data"], sort_keys=True, indent=2))
  322. return "\n".join(lines)
  323. def format_headers(headers):
  324. """Format headers into a list."""
  325. return [u"{}: {}".format(key, value) for key, value in headers.items()]
  326. if __name__ == "__main__":
  327. cli()