Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
getLogger,
handleError,
isValidGraphName,
isValidGrpcNamingScheme,
isValidGrpcSubgraphRoutingURL,
isValidLabels,
} from '../../util.js';
import { UnauthorizedError } from '../../errors/errors.js';
Expand Down Expand Up @@ -167,14 +167,19 @@ export function createFederatedSubgraph(
admissionErrors: [],
};
}
// For GRPC_SERVICE subgraphs, validate that routing URL follows gRPC naming scheme
if (req.type === SubgraphType.GRPC_SERVICE && !isValidGrpcNamingScheme(routingUrl)) {
// For GRPC_SERVICE subgraphs, the routing URL may use either a gRPC
// naming scheme (for native gRPC dialing) or http(s):// (for ConnectRPC).
// The router selects the actual transport at request time via the
// `grpc_protocol` configuration block.
if (req.type === SubgraphType.GRPC_SERVICE && !isValidGrpcSubgraphRoutingURL(routingUrl)) {
return {
response: {
code: EnumStatusCode.ERR,
details:
`Routing URL must follow gRPC naming scheme. ` +
`See https://grpc.io/docs/guides/custom-name-resolution/ for examples.`,
`Routing URL "${routingUrl}" is not a valid gRPC subgraph URL. ` +
`Use http(s)://host:port for ConnectRPC or a gRPC naming scheme ` +
`(e.g. dns:///host:port) for native gRPC. ` +
`See https://grpc.io/docs/guides/custom-name-resolution/ for the gRPC schemes.`,
},
compositionErrors: [],
admissionErrors: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
getLogger,
handleError,
isValidGraphName,
isValidGrpcNamingScheme,
isValidGrpcSubgraphRoutingURL,
isValidLabels,
isValidPluginVersion,
} from '../../util.js';
Expand Down Expand Up @@ -402,14 +402,18 @@ export function publishFederatedSubgraph(
proposalMatchMessage,
};
}
// For GRPC_SERVICE subgraphs, validate that routing URL follows gRPC naming scheme
if (req.type === SubgraphType.GRPC_SERVICE && !isValidGrpcNamingScheme(routingUrl)) {
// For GRPC_SERVICE subgraphs the routing URL may use either a gRPC
// naming scheme (native gRPC) or http(s):// (ConnectRPC). The router
// selects the actual transport via the `grpc_protocol` config block.
if (req.type === SubgraphType.GRPC_SERVICE && !isValidGrpcSubgraphRoutingURL(routingUrl)) {
return {
response: {
code: EnumStatusCode.ERR,
details:
`Routing URL must follow gRPC naming scheme. ` +
`See https://grpc.io/docs/guides/custom-name-resolution/ for examples.`,
`Routing URL "${routingUrl}" is not a valid gRPC subgraph URL. ` +
`Use http(s)://host:port for ConnectRPC or a gRPC naming scheme ` +
`(e.g. dns:///host:port) for native gRPC. ` +
`See https://grpc.io/docs/guides/custom-name-resolution/ for the gRPC schemes.`,
},
compositionErrors: [],
deploymentErrors: [],
Expand Down
14 changes: 9 additions & 5 deletions controlplane/src/core/bufservices/subgraph/updateSubgraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
formatWebsocketSubprotocol,
getLogger,
handleError,
isValidGrpcNamingScheme,
isValidGrpcSubgraphRoutingURL,
isValidLabels,
} from '../../util.js';
import { OrganizationWebhookService } from '../../webhooks/OrganizationWebhookService.js';
Expand Down Expand Up @@ -152,18 +152,22 @@ export function updateSubgraph(
compositionWarnings: [],
};
}
// For GRPC_SERVICE subgraphs, validate that routing URL follows gRPC naming scheme
// For GRPC_SERVICE subgraphs the routing URL may use either a gRPC
// naming scheme (native gRPC) or http(s):// (ConnectRPC). The router
// selects the actual transport via the `grpc_protocol` config block.
if (
req.routingUrl !== undefined &&
subgraph.type === formatSubgraphType(SubgraphType.GRPC_SERVICE) &&
!isValidGrpcNamingScheme(req.routingUrl)
!isValidGrpcSubgraphRoutingURL(req.routingUrl)
) {
return {
response: {
code: EnumStatusCode.ERR,
details:
`Routing URL must follow gRPC naming scheme. ` +
`See https://grpc.io/docs/guides/custom-name-resolution/ for examples.`,
`Routing URL "${req.routingUrl}" is not a valid gRPC subgraph URL. ` +
`Use http(s)://host:port for ConnectRPC or a gRPC naming scheme ` +
`(e.g. dns:///host:port) for native gRPC. ` +
`See https://grpc.io/docs/guides/custom-name-resolution/ for the gRPC schemes.`,
},
compositionErrors: [],
deploymentErrors: [],
Expand Down
36 changes: 36 additions & 0 deletions controlplane/src/core/util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
extractOperationNames,
hasLabelsChanged,
isValidGrpcNamingScheme,
isValidGrpcSubgraphRoutingURL,
isValidLabels,
isValidNamespaceName,
normalizePagination,
Expand Down Expand Up @@ -524,3 +525,38 @@ describe('isValidGrpcNamingScheme', () => {
});
});
});

describe('isValidGrpcSubgraphRoutingURL', () => {
test('accepts http URLs (ConnectRPC)', () => {
expect(isValidGrpcSubgraphRoutingURL('http://localhost:8080')).toBe(true);
expect(isValidGrpcSubgraphRoutingURL('http://example.com:443')).toBe(true);
expect(isValidGrpcSubgraphRoutingURL('http://example.com:443/grpc')).toBe(true);
});

test('accepts https URLs (ConnectRPC)', () => {
expect(isValidGrpcSubgraphRoutingURL('https://api.example.com')).toBe(true);
expect(isValidGrpcSubgraphRoutingURL('https://api.example.com:8443/v1')).toBe(true);
});

test('case-insensitive on http(s) scheme', () => {
expect(isValidGrpcSubgraphRoutingURL('HTTP://localhost:8080')).toBe(true);
expect(isValidGrpcSubgraphRoutingURL('HTTPS://api.example.com')).toBe(true);
});

test('rejects malformed http URLs', () => {
expect(isValidGrpcSubgraphRoutingURL('http://')).toBe(false);
expect(isValidGrpcSubgraphRoutingURL('https://')).toBe(false);
});

test('delegates to isValidGrpcNamingScheme for non-http schemes', () => {
expect(isValidGrpcSubgraphRoutingURL('dns:///example.com:8080')).toBe(true);
expect(isValidGrpcSubgraphRoutingURL('localhost:8080')).toBe(true);
expect(isValidGrpcSubgraphRoutingURL('unix:/tmp/sock')).toBe(true);
expect(isValidGrpcSubgraphRoutingURL('ftp://example.com')).toBe(false);
});

test('rejects empty / whitespace-only', () => {
expect(isValidGrpcSubgraphRoutingURL('')).toBe(false);
expect(isValidGrpcSubgraphRoutingURL(' ')).toBe(false);
});
});
31 changes: 31 additions & 0 deletions controlplane/src/core/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -902,3 +902,34 @@ export function isValidGrpcNamingScheme(url: string): boolean {
}
}
}

/**
* Validates a routing URL accepted for a GRPC_SERVICE subgraph. The router
* can reach a gRPC subgraph over either native gRPC (via the gRPC name
* resolver schemes) or ConnectRPC over HTTP/1.1, and the protocol is chosen
* at request time by the router via the `grpc_protocol` configuration block.
*
* Accepts:
* - any URL using one of the gRPC naming schemes recognised by
* isValidGrpcNamingScheme (dns:, unix:, unix-abstract:, vsock:, ipv4:, ipv6:),
* - any well-formed http:// or https:// URL.
*/
export function isValidGrpcSubgraphRoutingURL(url: string): boolean {
const value = url.trim();
if (!value) {
return false;
}

const lower = value.toLowerCase();
if (lower.startsWith('http://') || lower.startsWith('https://')) {
try {
// eslint-disable-next-line no-new
new URL(value);
return true;
} catch {
return false;
}
}

return isValidGrpcNamingScheme(value);
}
16 changes: 5 additions & 11 deletions controlplane/test/subgraph/create-subgraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,15 +930,16 @@ describe('Create subgraph tests', () => {
expect(createGrpcServiceSubgraphResp.response?.details).toBe('Routing URL "invalid-url" is not a valid URL');
});

test('Should not allow creating a GRPC service subgraph with HTTP/HTTPS routing URL', async (testContext) => {
test('Should allow creating a GRPC service subgraph with an HTTP/HTTPS routing URL (ConnectRPC)', async (testContext) => {
const { client, server } = await SetupTest({
dbname,
});
testContext.onTestFinished(() => server.close());

const grpcServiceLabel = genUniqueLabel('service');

// Test HTTP URL
// ConnectRPC subgraphs serve over plain HTTP, so http:// must be a
// legitimate routing URL even though the subgraph type is GRPC_SERVICE.
const createGrpcServiceSubgraphRespHttp = await client.createFederatedSubgraph({
name: genID('grpc-service-http'),
namespace: DEFAULT_NAMESPACE,
Expand All @@ -947,12 +948,8 @@ describe('Create subgraph tests', () => {
labels: [grpcServiceLabel],
});

expect(createGrpcServiceSubgraphRespHttp.response?.code).toBe(EnumStatusCode.ERR);
expect(createGrpcServiceSubgraphRespHttp.response?.details).toContain(
'Routing URL must follow gRPC naming scheme',
);
expect(createGrpcServiceSubgraphRespHttp.response?.code).toBe(EnumStatusCode.OK);

// Test HTTPS URL
const createGrpcServiceSubgraphRespHttps = await client.createFederatedSubgraph({
name: genID('grpc-service-https'),
namespace: DEFAULT_NAMESPACE,
Expand All @@ -961,10 +958,7 @@ describe('Create subgraph tests', () => {
labels: [grpcServiceLabel],
});

expect(createGrpcServiceSubgraphRespHttps.response?.code).toBe(EnumStatusCode.ERR);
expect(createGrpcServiceSubgraphRespHttps.response?.details).toContain(
'Routing URL must follow gRPC naming scheme',
);
expect(createGrpcServiceSubgraphRespHttps.response?.code).toBe(EnumStatusCode.OK);
});

test('Should allow creating a GRPC service subgraph with valid gRPC naming scheme URLs', async (testContext) => {
Expand Down
13 changes: 6 additions & 7 deletions controlplane/test/subgraph/publish-subgraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1100,15 +1100,17 @@ describe('Publish subgraph tests', () => {
},
);

test('Should not allow publishing a GRPC service subgraph with HTTP/HTTPS routing URL', async (testContext) => {
test('Should allow publishing a GRPC service subgraph with an HTTP/HTTPS routing URL (ConnectRPC)', async (testContext) => {
const { client, server } = await SetupTest({
dbname,
});
testContext.onTestFinished(() => server.close());

const grpcServiceLabel = genUniqueLabel('grpc-service');

// Test HTTP URL when creating and publishing in one step
// ConnectRPC subgraphs serve over plain HTTP, so the create-and-publish
// path must accept http:// (and https://) URLs even though the subgraph
// type is GRPC_SERVICE.
const publishResponseHttp = await client.publishFederatedSubgraph({
name: genID('grpc-service-http'),
namespace: 'default',
Expand All @@ -1119,10 +1121,8 @@ describe('Publish subgraph tests', () => {
labels: [grpcServiceLabel],
});

expect(publishResponseHttp.response?.code).toBe(EnumStatusCode.ERR);
expect(publishResponseHttp.response?.details).toContain('Routing URL must follow gRPC naming scheme');
expect(publishResponseHttp.response?.code).toBe(EnumStatusCode.OK);

// Test HTTPS URL when creating and publishing in one step
const publishResponseHttps = await client.publishFederatedSubgraph({
name: genID('grpc-service-https'),
namespace: 'default',
Expand All @@ -1133,8 +1133,7 @@ describe('Publish subgraph tests', () => {
labels: [grpcServiceLabel],
});

expect(publishResponseHttps.response?.code).toBe(EnumStatusCode.ERR);
expect(publishResponseHttps.response?.details).toContain('Routing URL must follow gRPC naming scheme');
expect(publishResponseHttps.response?.code).toBe(EnumStatusCode.OK);
});

test('Should allow publishing a GRPC service subgraph with valid gRPC naming scheme URLs', async (testContext) => {
Expand Down
14 changes: 6 additions & 8 deletions controlplane/test/subgraph/update-subgraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,14 +226,14 @@ describe('Update subgraph tests', () => {
});

describe('GRPC Service subgraph update tests', () => {
test('Should not allow updating a GRPC service subgraph with HTTP/HTTPS routing URL', async (testContext) => {
test('Should allow updating a GRPC service subgraph with an HTTP/HTTPS routing URL (ConnectRPC)', async (testContext) => {
const { client, server } = await SetupTest({ dbname });
testContext.onTestFinished(() => server.close());

const grpcServiceName = genID('grpc-service');
const grpcServiceLabel = genUniqueLabel('grpc-service');

// First create a GRPC service subgraph with valid gRPC naming scheme
// Create with a gRPC naming scheme URL first.
const createResp = await client.createFederatedSubgraph({
name: grpcServiceName,
namespace: DEFAULT_NAMESPACE,
Expand All @@ -244,25 +244,23 @@ describe('Update subgraph tests', () => {

expect(createResp.response?.code).toBe(EnumStatusCode.OK);

// Try to update with HTTP URL
// Switching to ConnectRPC means the subgraph is now reachable over
// plain HTTP, so an http:// routing URL is the right thing to set.
const updateResponseHttp = await client.updateSubgraph({
name: grpcServiceName,
namespace: DEFAULT_NAMESPACE,
routingUrl: 'http://localhost:8080',
});

expect(updateResponseHttp.response?.code).toBe(EnumStatusCode.ERR);
expect(updateResponseHttp.response?.details).toContain('Routing URL must follow gRPC naming scheme');
expect(updateResponseHttp.response?.code).toBe(EnumStatusCode.OK);

// Try to update with HTTPS URL
const updateResponseHttps = await client.updateSubgraph({
name: grpcServiceName,
namespace: DEFAULT_NAMESPACE,
routingUrl: 'https://example.com:8080',
});

expect(updateResponseHttps.response?.code).toBe(EnumStatusCode.ERR);
expect(updateResponseHttps.response?.details).toContain('Routing URL must follow gRPC naming scheme');
expect(updateResponseHttps.response?.code).toBe(EnumStatusCode.OK);
});

test('Should allow updating a GRPC service subgraph with valid gRPC naming scheme URLs', async (testContext) => {
Expand Down
9 changes: 9 additions & 0 deletions demo/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ plugin-generate:
$(wgc_router) plugin build ./pkg/subgraphs/projects --generate-only --go-module-path github.com/wundergraph/cosmo/demo/pkg/subgraphs/projects
$(wgc_router) plugin build ./pkg/subgraphs/courses --generate-only

# Regenerate the projects subgraph protobuf code (messages + gRPC + ConnectRPC)
# via buf. Use this after editing pkg/subgraphs/projects/generated/service.proto
# or after bumping any of protoc-gen-go, protoc-gen-go-grpc, protoc-gen-connect-go.
# The Connect handler is what makes the standalone subgraph reachable over both
# gRPC and ConnectRPC from the same H2C endpoint (see cmd/service/main.go).
.PHONY: projects-buf-generate
projects-buf-generate:
cd ./pkg/subgraphs/projects && buf generate generated

plugin-build-ci-bun-binary:
$(wgc_router_ci) plugin build ./pkg/subgraphs/courses --yes

Expand Down
27 changes: 27 additions & 0 deletions demo/pkg/subgraphs/projects/buf.gen.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
version: v2
# service.proto declares `option go_package = "github.com/wundergraph/cosmo/demo/pkg/subgraphs/projects"`
# but the generated message/gRPC code lives at the .../generated subpath.
# The Connect plugin imports the message types via the proto's go_package,
# so we remap it to the actual generated path with M=.
plugins:
# Messages and gRPC server/client. The output is kept in sync with the
# existing wgc-driven flow so that the .pb.go and _grpc.pb.go files do not
# change shape after running buf generate.
- local: protoc-gen-go
out: generated
opt:
- paths=source_relative
- Mservice.proto=github.com/wundergraph/cosmo/demo/pkg/subgraphs/projects/generated
- local: protoc-gen-go-grpc
out: generated
opt:
- paths=source_relative
- Mservice.proto=github.com/wundergraph/cosmo/demo/pkg/subgraphs/projects/generated
# ConnectRPC handler/client. The handler also serves gRPC and gRPC-Web on
# the same HTTP endpoint, so the demo subgraph can be reached over either
# protocol from the router. Output goes to generated/projectsconnect/.
- local: protoc-gen-connect-go
out: generated
opt:
- paths=source_relative
- Mservice.proto=github.com/wundergraph/cosmo/demo/pkg/subgraphs/projects/generated
13 changes: 13 additions & 0 deletions demo/pkg/subgraphs/projects/buf.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
version: v2
modules:
- path: generated
breaking:
use:
- FILE
lint:
use:
- DEFAULT
except:
- PACKAGE_VERSION_SUFFIX
- DIRECTORY_SAME_PACKAGE
- FILE_LOWER_SNAKE_CASE
Loading
Loading