index.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. /**
  2. * The read-only JSON API the viewer reads its screens from.
  3. *
  4. * Six endpoints, one per screen, each answering in a single round-trip — the
  5. * same principle as `codegraph_explore`: return enough that the caller does not
  6. * have to ask a follow-up question. Everything here is a *reader* of the
  7. * existing schema; nothing indexes, resolves, or writes.
  8. *
  9. * ```
  10. * GET /api/stats what this index is and how much to trust it
  11. * GET /api/search?q= the search palette
  12. * GET /api/node/<id> the Symbol view: rails, members, tests, blast radius
  13. * GET /api/source?file=&from=&to= verbatim source, with a drift verdict
  14. * GET /api/file/<path> the File view: outline and import rails
  15. * GET /api/routes the URL to handler map, when there is one
  16. * ```
  17. *
  18. * It mounts on the `api` seam of `startUiServer`, which means it sits *behind*
  19. * the loopback boundary in `security.ts`: the `Host` allowlist, the absence of
  20. * CORS headers and the GET/HEAD restriction are already enforced by the time a
  21. * handler here runs. The one obligation that remains ours is the read
  22. * chokepoint — `resolveProjectFile` for anything that touches the repository —
  23. * and it lives in `source.ts`, the only module here that opens a file.
  24. */
  25. import type { UiApiHandler, UiRequestContext } from '../index';
  26. import { PathRefusalError } from '../security';
  27. import { GraphSession } from './session';
  28. import { ApiError, badRequest, fail, notFound, ok } from './respond';
  29. import { buildStats } from './stats';
  30. import { buildSearch } from './search';
  31. import { buildNode } from './node';
  32. import { buildSource } from './source';
  33. import { buildFile } from './file';
  34. import { buildRoutes } from './routes';
  35. export { GraphSession } from './session';
  36. export { ApiError } from './respond';
  37. export * from './wire';
  38. /**
  39. * A mounted API, plus the handle it holds open.
  40. *
  41. * `close()` releases the index; the CLI calls it on Ctrl-C so the process does
  42. * not exit with a live SQLite connection.
  43. */
  44. export interface GraphApi {
  45. handler: UiApiHandler;
  46. close(): void;
  47. }
  48. export interface GraphApiOptions {
  49. /** Absolute path of the indexed project to read. */
  50. projectRoot: string;
  51. }
  52. /** What `GET /api` answers: the endpoint list, for anyone poking at it by hand. */
  53. const API_INDEX = {
  54. name: 'codegraph ui',
  55. readOnly: true,
  56. endpoints: [
  57. { path: '/api/stats', description: 'Index state, graph counts, detected frameworks.' },
  58. { path: '/api/search', description: 'Ranked symbol search.', params: ['q', 'limit'] },
  59. { path: '/api/node/<id>', description: 'One symbol: callers, callees, members, tests, blast radius.' },
  60. {
  61. path: '/api/source',
  62. description: 'Verbatim source for an indexed file, omitted when it has drifted on disk.',
  63. params: ['file', 'from', 'to'],
  64. },
  65. { path: '/api/file/<path>', description: 'One file: outline and import rails.' },
  66. { path: '/api/routes', description: 'URL to handler map, when the project is a routed app.', params: ['limit'] },
  67. ],
  68. };
  69. export function createGraphApi(options: GraphApiOptions): GraphApi {
  70. const session = new GraphSession(options.projectRoot);
  71. const handler: UiApiHandler = (_req, res, ctx) => {
  72. const route = normalize(ctx.pathname);
  73. try {
  74. switch (route) {
  75. case '/api':
  76. return ok(res, API_INDEX, ctx.method);
  77. case '/api/stats':
  78. return ok(res, buildStats(session.acquire(), ctx.projectRoot), ctx.method);
  79. case '/api/search':
  80. return ok(res, buildSearch(session.acquire(), ctx.query), ctx.method);
  81. case '/api/routes':
  82. return ok(res, buildRoutes(session.acquire(), ctx.query), ctx.method);
  83. case '/api/source':
  84. return ok(res, buildSource(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
  85. default:
  86. return dispatchPathRoutes(route, res, ctx, session);
  87. }
  88. } catch (err) {
  89. // A refusal from the read chokepoint is a 403 with the reason attached —
  90. // the request asked for something outside the project, and there is no
  91. // version of it we would serve.
  92. if (err instanceof PathRefusalError) {
  93. return fail(res, new ApiError('refused', err.message), ctx.method);
  94. }
  95. return fail(res, err, ctx.method);
  96. }
  97. };
  98. return { handler, close: () => session.close() };
  99. }
  100. /**
  101. * The two endpoints that carry their argument in the path.
  102. *
  103. * `ctx.pathname` is already percent-decoded, so a node id or a file path
  104. * containing `/` (`file:src/a.ts`) arrives whole — the remainder after the
  105. * prefix IS the argument, slashes and all. Node ids are opaque: they go
  106. * straight to an exact lookup, and anything that names nothing is a 404. File
  107. * paths go through the read chokepoint before anything is opened.
  108. */
  109. function dispatchPathRoutes(
  110. route: string,
  111. res: Parameters<UiApiHandler>[1],
  112. ctx: UiRequestContext,
  113. session: GraphSession
  114. ): boolean {
  115. const nodeId = suffixAfter(route, '/api/node/');
  116. if (nodeId !== null) {
  117. if (nodeId === '') throw badRequest('No symbol id was given. Use /api/node/<id>.');
  118. return ok(res, buildNode(session.acquire(), ctx.projectRoot, nodeId), ctx.method);
  119. }
  120. const filePath = suffixAfter(route, '/api/file/');
  121. if (filePath !== null) {
  122. if (filePath === '') throw badRequest('No file path was given. Use /api/file/<path>.');
  123. return ok(res, buildFile(session.acquire(), ctx.projectRoot, filePath), ctx.method);
  124. }
  125. // `/api/node` and `/api/file` with no argument at all, so the message can say
  126. // what the endpoint wants instead of falling through to a bare 404.
  127. if (route === '/api/node' || route === '/api/file') {
  128. throw badRequest(`${route} needs an argument: ${route}/<${route.endsWith('node') ? 'id' : 'path'}>.`);
  129. }
  130. throw notFound(
  131. `No such endpoint: ${route}`,
  132. 'GET /api lists everything this server answers.'
  133. );
  134. }
  135. /** Drop a single trailing slash, so `/api/stats/` and `/api/stats` are one route. */
  136. function normalize(pathname: string): string {
  137. return pathname.length > 4 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
  138. }
  139. function suffixAfter(route: string, prefix: string): string | null {
  140. return route.startsWith(prefix) ? route.slice(prefix.length) : null;
  141. }