Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions scripts/fixtures/grpc-connect/buf.gen.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version: v1
plugins:
- plugin: connect-go
out: gen
7 changes: 7 additions & 0 deletions scripts/fixtures/grpc-connect/eliza.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
syntax = "proto3";
package connectrpc.eliza.v1;
service ElizaService {
rpc Say(SayRequest) returns (SayResponse);
}
message SayRequest { string sentence = 1; }
message SayResponse { string sentence = 1; }
4 changes: 4 additions & 0 deletions scripts/fixtures/grpc-mixed/svc-connect/buf.gen.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version: v1
plugins:
- plugin: connect-go
out: gen
4 changes: 4 additions & 0 deletions scripts/fixtures/grpc-mixed/svc-connect/eliza.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
syntax="proto3";
package eliza.v1;
service ElizaService { rpc Say(Req) returns (Resp); }
message Req{} message Resp{}
4 changes: 4 additions & 0 deletions scripts/fixtures/grpc-mixed/svc-plain/bank.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
syntax="proto3";
package bank.v1;
service BankService { rpc GetBalance(Req) returns (Resp); }
message Req{} message Resp{}
30 changes: 30 additions & 0 deletions scripts/fixtures/grpc-proto/api/v1/orders.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Orders API — license header
/* a block comment
spanning multiple lines with a stray */
syntax = "proto3";
package shop.v1;

import "google/protobuf/empty.proto";

service OrderService {
// Create an order
rpc CreateOrder(CreateOrderRequest) returns (Order) {
option (google.api.http) = {
post: "/v1/{parent=shops/*}/orders"
body: "*"
};
}
rpc StreamOrders(StreamOrdersRequest)
returns (stream Order);
rpc UploadOrders(stream Order) returns (UploadSummary);
rpc Chat(stream ChatMsg) returns (stream ChatMsg);
rpc DeleteOrder(DeleteOrderRequest) returns (google.protobuf.Empty);
}

service AdminService {
rpc Shutdown(ShutdownRequest) returns (google.protobuf.Empty);
}

message CreateOrderRequest { string parent = 1; }
message Order { string id = 1; }
// rpc CommentedOut(Foo) returns (Bar);
11 changes: 11 additions & 0 deletions scripts/fixtures/nestjs-grpc-proto/hero.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
syntax = "proto3";
package hero;
service HeroesService {
rpc FindOne(HeroById) returns (Hero);
rpc FindAll(Empty) returns (Heroes);
rpc StreamHeroes(stream HeroById) returns (stream Hero);
}
message HeroById { int32 id = 1; }
message Hero { int32 id = 1; string name = 2; }
message Empty {}
message Heroes { repeated Hero heroes = 1; }
1 change: 1 addition & 0 deletions scripts/fixtures/nestjs-grpc-proto/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "name": "nestjs-grpc-fixture", "dependencies": { "@nestjs/core": "^10.0.0", "@nestjs/microservices": "^10.0.0" } }
20 changes: 20 additions & 0 deletions scripts/fixtures/nestjs-grpc-proto/src/heroes.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Controller } from '@nestjs/common';
import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices';

@Controller()
export class HeroesController {
@GrpcMethod('hero.HeroesService', 'FindOne')
findOne(data: HeroById): Hero { return {} as Hero; }

@GrpcMethod('hero.HeroesService', 'FindAll')
findAll(data: Empty): Heroes { return {} as Heroes; }

@GrpcStreamMethod('hero.HeroesService', 'StreamHeroes')
streamHeroes(messages: any) { return messages; }
}

@Controller()
export class BillingController {
@GrpcMethod()
charge(data: any): any { return {}; }
}
1 change: 1 addition & 0 deletions scripts/fixtures/nestjs-grpc/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "name": "nestjs-grpc-fixture", "dependencies": { "@nestjs/core": "^10.0.0", "@nestjs/microservices": "^10.0.0" } }
14 changes: 14 additions & 0 deletions scripts/fixtures/nestjs-grpc/src/heroes.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Controller } from '@nestjs/common';
import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices';

@Controller()
export class HeroesController {
@GrpcMethod('HeroesService', 'FindOne')
findOne(data: HeroById): Hero { return {} as Hero; }

@GrpcMethod('HeroesService')
findAll(data: Empty): Heroes { return {} as Heroes; }

@GrpcStreamMethod('HeroesService', 'StreamHeroes')
streamHeroes(messages: any) { return messages; }
}
86 changes: 86 additions & 0 deletions src/extractors/grpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test";
import { resolve } from "path";
import { grpc } from "./grpc.ts";
import { createScanContext } from "../scan-context.ts";
import { map } from "../mapper.ts";
import { getExtractor } from "./index.ts";

function extract(fixture: string) {
const dir = resolve(import.meta.dir, "../../scripts/fixtures", fixture);
return grpc.extract(createScanContext(dir));
}

describe("grpc proto extraction", () => {
const eps = extract("grpc-proto");
const byPath = (p: string) => eps.find((e) => e.path === p);

test("one endpoint per rpc across all services", () => {
expect(eps.length).toBe(6);
});

test("wire path is /package.Service/Method", () => {
expect(byPath("/shop.v1.OrderService/CreateOrder")).toBeDefined();
expect(byPath("/shop.v1.AdminService/Shutdown")).toBeDefined();
});

test("detects all four streaming types", () => {
expect(
byPath("/shop.v1.OrderService/CreateOrder")!.grpc!.streamingType,
).toBe("unary");
expect(
byPath("/shop.v1.OrderService/StreamOrders")!.grpc!.streamingType,
).toBe("server_stream");
expect(
byPath("/shop.v1.OrderService/UploadOrders")!.grpc!.streamingType,
).toBe("client_stream");
expect(byPath("/shop.v1.OrderService/Chat")!.grpc!.streamingType).toBe(
"bidi",
);
});

test("tags transport and framework as grpc", () => {
const e = byPath("/shop.v1.OrderService/CreateOrder")!;
expect(e.transport).toBe("grpc");
expect(e.framework).toBe("grpc");
expect(e.grpc!.serviceFqn).toBe("shop.v1.OrderService");
expect(e.grpc!.method).toBe("CreateOrder");
});

test("ignores commented-out rpcs", () => {
expect(byPath("/shop.v1.OrderService/CommentedOut")).toBeUndefined();
});

test("survives braces and slashes inside option-string paths", () => {
// DeleteOrder is declared AFTER an `option (google.api.http)` block whose
// path literal contains `{parent=shops/*}` — the exact trap that a
// non-string-aware comment/brace scanner chokes on.
expect(byPath("/shop.v1.OrderService/DeleteOrder")).toBeDefined();
});
});

describe("grpc connect detection", () => {
const eps = extract("grpc-connect");
test("maps to the connect transport when a connect toolchain is present", () => {
expect(eps.length).toBe(1);
expect(eps[0]!.transport).toBe("connect");
expect(eps[0]!.framework).toBe("connect");
expect(eps[0]!.path).toBe("/connectrpc.eliza.v1.ElizaService/Say");
});

test("`--framework connect` resolves the grpc extractor", () => {
expect(getExtractor("connect")).toBe(grpc);
const dir = resolve(import.meta.dir, "../../scripts/fixtures/grpc-connect");
const grpcEps = map(dir, {
frameworkOverride: "connect",
}).endpoints.all.filter((e) => e.grpc);
expect(grpcEps.length).toBe(1);
expect(grpcEps[0]!.transport).toBe("connect");
});

test("scopes connect vs plain gRPC per package in a monorepo", () => {
const mixed = extract("grpc-mixed");
const byPath = (p: string) => mixed.find((e) => e.path === p);
expect(byPath("/eliza.v1.ElizaService/Say")!.transport).toBe("connect");
expect(byPath("/bank.v1.BankService/GetBalance")!.transport).toBe("grpc");
});
});
174 changes: 174 additions & 0 deletions src/extractors/grpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { dirname, join } from "path";
import type {
EndpointInfo,
Extractor,
FrameworkId,
GrpcStreaming,
ScanContext,
} from "../types.ts";
import { buildLineIndex, endpoint } from "../utils.ts";

const CONNECT_HINTS = [
"connectrpc.com/connect",
"@connectrpc/",
"protoc-gen-connect",
"connect-go",
"connect-es",
"connectrpc",
];

const CONNECT_DEP_FILES = [
"buf.gen.yaml",
"buf.yaml",
"package.json",
"go.mod",
];

// Connect vs. plain gRPC is decided per proto by walking up to the nearest
// package that declares a Connect/Buf toolchain — so a Connect service in one
// package doesn't mislabel vanilla gRPC protos elsewhere in a monorepo. Results
// are memoized per directory (ancestors are shared across sibling protos).
function makeConnectResolver(ctx: ScanContext): (file: string) => boolean {
const memo = new Map<string, boolean>();
const root = ctx.repoPath;
const dirUsesConnect = (dir: string): boolean => {
const cached = memo.get(dir);
if (cached !== undefined) return cached;
let result = false;
for (const name of CONNECT_DEP_FILES) {
const c = ctx.readFile(join(dir, name));
if (c && CONNECT_HINTS.some((h) => c.toLowerCase().includes(h))) {
result = true;
break;
}
}
if (!result && dir !== root && dir.startsWith(root)) {
const parent = dirname(dir);
if (parent !== dir) result = dirUsesConnect(parent);
}
memo.set(dir, result);
return result;
};
return (file) => dirUsesConnect(dirname(file));
}

// Blank out comments while preserving byte offsets and newlines. String-aware:
// proto path options carry `/*`, `*/` and `//` inside quoted literals
// (e.g. "/v1/{name=projects/*/topics/*}"), which must NOT be read as comments.
function stripComments(src: string): string {
let out = "";
for (let i = 0; i < src.length; ) {
const c = src[i]!;
if (c === '"' || c === "'") {
out += c;
i++;
while (i < src.length && src[i] !== c) {
if (src[i] === "\\" && i + 1 < src.length) {
out += src[i]! + src[i + 1]!;
i += 2;
} else {
out += src[i];
i++;
}
}
if (i < src.length) {
out += src[i];
i++;
}
} else if (c === "/" && src[i + 1] === "/") {
while (i < src.length && src[i] !== "\n") {
out += " ";
i++;
}
} else if (c === "/" && src[i + 1] === "*") {
out += " ";
i += 2;
while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) {
out += src[i] === "\n" ? "\n" : " ";
i++;
}
if (i < src.length) {
out += " ";
i += 2;
}
} else {
out += c;
i++;
}
}
return out;
}

function streaming(client: boolean, server: boolean): GrpcStreaming {
if (client && server) return "bidi";
if (client) return "client_stream";
if (server) return "server_stream";
return "unary";
}

const PACKAGE_RE = /\bpackage\s+([A-Za-z_][\w.]*)\s*;/;
const SERVICE_RE = /\bservice\s+([A-Za-z_]\w*)\s*\{/g;
const RPC_RE =
/\brpc\s+([A-Za-z_]\w*)\s*\(\s*(stream\s+)?[.\w]+\s*\)\s*returns\s*\(\s*(stream\s+)?[.\w]+\s*\)/g;

export const grpc: Extractor = {
id: "grpc",
detect: (_repoPath, ctx) => ctx.iterFiles([".proto"]).length > 0,
extract(ctx) {
const endpoints: EndpointInfo[] = [];
const connectForFile = makeConnectResolver(ctx);

Comment thread
cursor[bot] marked this conversation as resolved.
for (const file of ctx.iterFiles([".proto"])) {
const raw = ctx.readFile(file);
if (!raw) continue;
const framework: FrameworkId = connectForFile(file) ? "connect" : "grpc";
const transport = framework === "connect" ? "connect" : "grpc";
const src = stripComments(raw);
const rel = ctx.rel(file);
const lines = buildLineIndex(raw);
const pkg = PACKAGE_RE.exec(src)?.[1] ?? "";

// Services never nest and rpcs only live inside them, so each rpc belongs
// to the nearest service declared before it — no brace matching needed.
const services: { name: string; index: number }[] = [];
SERVICE_RE.lastIndex = 0;
let s: RegExpExecArray | null;
while ((s = SERVICE_RE.exec(src))) {
services.push({ name: s[1]!, index: s.index });
}
if (!services.length) continue;

RPC_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = RPC_RE.exec(src))) {
let svc: { name: string; index: number } | null = null;
for (const cand of services) {
if (cand.index < m.index) svc = cand;
else break;
}
if (!svc) continue;

const serviceFqn = pkg ? `${pkg}.${svc.name}` : svc.name;
const method = m[1]!;
endpoints.push(
endpoint({
method: "ANY",
path: `/${serviceFqn}/${method}`,
handler: method,
file: rel,
line: lines.lineAt(m.index),
framework,
transport,
grpc: {
serviceFqn,
method,
streamingType: streaming(!!m[2], !!m[3]),
},
}),
);
}
}

return endpoints;
},
};
5 changes: 5 additions & 0 deletions src/extractors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { laravel } from "./laravel.ts";
import { sst } from "./sst.ts";
import { serverActions } from "./server-actions.ts";
import { openapi } from "./openapi.ts";
import { grpc } from "./grpc.ts";

const ALL_EXTRACTORS: Extractor[] = [
flask,
Expand All @@ -32,11 +33,15 @@ const ALL_EXTRACTORS: Extractor[] = [
sst,
serverActions,
openapi,
grpc,
Comment thread
cursor[bot] marked this conversation as resolved.
];

const EXTRACTOR_MAP = new Map<FrameworkId, Extractor>(
ALL_EXTRACTORS.map((e) => [e.id, e]),
);
// The grpc extractor emits both grpc and connect transports (it picks the
// variant from the toolchain), so `--framework connect` resolves to it too.
EXTRACTOR_MAP.set("connect", grpc);

export function getExtractor(id: FrameworkId): Extractor | undefined {
return EXTRACTOR_MAP.get(id);
Expand Down
Loading
Loading