From b08608530cd3bbe2a132b3209c922aff66271102 Mon Sep 17 00:00:00 2001 From: Sri Krishna <93153132+srikrsna-buf@users.noreply.github.com> Date: Mon, 22 Apr 2024 17:01:48 +0530 Subject: [PATCH] Tweak error codes according to the conformance suite (#1063) --- Makefile | 8 +- package-lock.json | 33 +-- .../connect-conformance/conformance-node.yaml | 3 +- .../conformance-web-node.yaml | 19 ++ .../connect-conformance/conformance-web.yaml | 2 +- packages/connect-conformance/package.json | 8 +- .../connect-conformance/src/conformance.ts | 2 +- .../conformance/v1/client_compat_pb.ts | 264 ++++++++++++------ .../connectrpc/conformance/v1/config_pb.ts | 193 ++++++++++++- .../conformance/v1/server_compat_pb.ts | 97 ++++--- .../conformance/v1/service_connect.ts | 16 +- .../connectrpc/conformance/v1/service_pb.ts | 51 +++- .../gen/connectrpc/conformance/v1/suite_pb.ts | 54 +++- .../connect-conformance/src/node/server.ts | 8 +- packages/connect-conformance/src/protocol.ts | 7 +- packages/connect-conformance/src/tls.ts | 79 ------ packages/connect-node-test/jasmine.json | 2 +- packages/connect-web-bench/README.md | 2 +- .../conformance/fail_server_streaming.spec.ts | 2 +- ...mplemented_server_streaming_method.spec.ts | 2 +- packages/connect-web/src/connect-transport.ts | 22 +- .../connect-web/src/grpc-web-transport.ts | 44 ++- packages/connect/src/promise-client.ts | 10 +- .../src/protocol-connect/end-stream.spec.ts | 12 +- .../src/protocol-connect/end-stream.ts | 4 +- .../src/protocol-connect/error-json.spec.ts | 98 +++---- .../src/protocol-connect/error-json.ts | 12 +- .../connect/src/protocol-connect/transport.ts | 2 + .../validate-response.spec.ts | 7 + .../src/protocol-connect/validate-response.ts | 20 +- .../src/protocol-grpc-web/transport.ts | 32 ++- .../validate-response.spec.ts | 8 +- .../protocol-grpc-web/validate-response.ts | 22 +- .../connect/src/protocol-grpc/transport.ts | 29 +- .../protocol-grpc/validate-response.spec.ts | 8 +- .../src/protocol-grpc/validate-response.ts | 22 +- .../src/protocol/async-iterable.spec.ts | 2 +- .../connect/src/protocol/envelope.spec.ts | 2 +- packages/connect/src/protocol/envelope.ts | 2 +- .../src/protocol/invoke-implementation.ts | 8 +- .../src/protocol/serialization.spec.ts | 4 +- .../connect/src/protocol/serialization.ts | 2 +- 42 files changed, 801 insertions(+), 423 deletions(-) create mode 100644 packages/connect-conformance/conformance-web-node.yaml delete mode 100644 packages/connect-conformance/src/tls.ts diff --git a/Makefile b/Makefile index 29e1a3f84..73f645292 100644 --- a/Makefile +++ b/Makefile @@ -225,12 +225,12 @@ testnodeconformance: $(BIN)/node16 $(BIN)/node18 $(BIN)/node20 $(BIN)/node21 $(B .PHONY: testwebconformance testwebconformance: $(BUILD)/connect-conformance - npm run -w packages/connect-conformance test:web -- --browser chrome --headless - npm run -w packages/connect-conformance test:web -- --browser firefox --headless - npm run -w packages/connect-conformance test:web -- --browser node + npm run -w packages/connect-conformance test:web:chrome + npm run -w packages/connect-conformance test:web:firefox + npm run -w packages/connect-conformance test:web:node @# Requires one to enable the 'Allow Remote Automation' option in Safari's Develop menu. ifeq ($(NODE_OS),darwin) - npm run -w packages/connect-conformance test:web -- --browser safari --headless + npm run -w packages/connect-conformance test:web:safari endif .PHONY: testwebconformancelocal diff --git a/package-lock.json b/package-lock.json index 9b56f6174..7de14c6cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3244,19 +3244,6 @@ "printable-characters": "^1.0.42" } }, - "node_modules/asn1js": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", - "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", - "dependencies": { - "pvtsutils": "^1.3.2", - "pvutils": "^1.1.3", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/assert": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", @@ -7944,6 +7931,8 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" } @@ -8808,22 +8797,6 @@ "node": ">=12" } }, - "node_modules/pvtsutils": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", - "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", - "dependencies": { - "tslib": "^2.6.1" - } - }, - "node_modules/pvutils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", - "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/q": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", @@ -11396,10 +11369,8 @@ "@bufbuild/protobuf": "^1.7.2", "@connectrpc/connect": "1.4.0", "@connectrpc/connect-node": "1.4.0", - "asn1js": "^3.0.5", "esbuild": "^0.20.0", "fflate": "^0.8.1", - "node-forge": "^1.3.1", "tar-stream": "^3.1.7", "undici": "^5.28.4", "webdriverio": "^8.35.1" diff --git a/packages/connect-conformance/conformance-node.yaml b/packages/connect-conformance/conformance-node.yaml index 5f0b42aca..f0de5fa06 100644 --- a/packages/connect-conformance/conformance-node.yaml +++ b/packages/connect-conformance/conformance-node.yaml @@ -17,5 +17,4 @@ features: supportsTlsClientCerts: true supportsConnectGet: true supportsHalfDuplexBidiOverHttp1: false - requiresConnectVersionHeader: false - supportsMessageReceiveLimit: true + supportsMessageReceiveLimit: false diff --git a/packages/connect-conformance/conformance-web-node.yaml b/packages/connect-conformance/conformance-web-node.yaml new file mode 100644 index 000000000..479960305 --- /dev/null +++ b/packages/connect-conformance/conformance-web-node.yaml @@ -0,0 +1,19 @@ +features: + versions: + - HTTP_VERSION_1 + protocols: + - PROTOCOL_CONNECT + - PROTOCOL_GRPC_WEB + codecs: + - CODEC_PROTO + - CODEC_JSON + compressions: + - COMPRESSION_IDENTITY + streamTypes: + - STREAM_TYPE_UNARY + - STREAM_TYPE_SERVER_STREAM + supportsTls: false + supportsH2c: false + supportsConnectGet: true + supportsHalfDuplexBidiOverHttp1: false + supportsMessageReceiveLimit: false diff --git a/packages/connect-conformance/conformance-web.yaml b/packages/connect-conformance/conformance-web.yaml index db26c49b5..64225da26 100644 --- a/packages/connect-conformance/conformance-web.yaml +++ b/packages/connect-conformance/conformance-web.yaml @@ -18,4 +18,4 @@ features: supportsTlsClientCerts: false supportsConnectGet: true supportsHalfDuplexBidiOverHttp1: false - requiresConnectVersionHeader: false + supportsMessageReceiveLimit: false diff --git a/packages/connect-conformance/package.json b/packages/connect-conformance/package.json index a6a443c66..76076b2b0 100644 --- a/packages/connect-conformance/package.json +++ b/packages/connect-conformance/package.json @@ -9,11 +9,15 @@ "connectconformance": "bin/connectconformance" }, "scripts": { - "generate": "buf generate buf.build/connectrpc/conformance:v1.0.0-rc2", + "generate": "buf generate buf.build/connectrpc/conformance:v1.0.2", "clean": "rm -rf ./dist/cjs/*", "build": "npm run build:cjs && npm run build:esm", "build:cjs": "tsc --project tsconfig.json --module commonjs --outDir ./dist/cjs", "build:esm": "tsc --project tsconfig.json --module ES2015 --outDir ./dist/esm", + "test:web:chrome": "./bin/connectconformance --mode client --conf conformance-web.yaml -v -- ./bin/conformancewebclient --browser chrome --headless", + "test:web:firefox": "./bin/connectconformance --mode client --conf conformance-web.yaml -v --known-failing '**/server-stream/cancel-after-zero-responses' --known-failing '**/server-stream/cancel-after-responses' -- ./bin/conformancewebclient --browser firefox --headless", + "test:web:safari": "./bin/connectconformance --mode client --conf conformance-web.yaml -v -- ./bin/conformancewebclient --browser safari --headless", + "test:web:node": "./bin/connectconformance --mode client --conf conformance-web-node.yaml -v -- ./bin/conformancewebclient --browser node", "test:web": "./bin/connectconformance --mode client --conf conformance-web.yaml -v -- ./bin/conformancewebclient", "test:node:server": "./bin/connectconformance --mode server --conf conformance-node.yaml -v ./bin/conformancenodeserver", "test:node:client": "./bin/connectconformance --mode client --conf conformance-node.yaml -v ./bin/conformancenodeclient", @@ -23,8 +27,6 @@ "@bufbuild/protobuf": "^1.7.2", "@connectrpc/connect": "1.4.0", "@connectrpc/connect-node": "1.4.0", - "node-forge": "^1.3.1", - "asn1js": "^3.0.5", "fflate": "^0.8.1", "tar-stream": "^3.1.7", "undici": "^5.28.4", diff --git a/packages/connect-conformance/src/conformance.ts b/packages/connect-conformance/src/conformance.ts index 246e7c9ea..7fad8a06b 100644 --- a/packages/connect-conformance/src/conformance.ts +++ b/packages/connect-conformance/src/conformance.ts @@ -28,7 +28,7 @@ import { Readable } from "node:stream"; import { execFileSync } from "node:child_process"; import { fetch } from "undici"; -const version = "v1.0.0-rc1"; +const version = "v1.0.2"; const name = "connectconformance"; const downloadUrl = `https://github.com/connectrpc/conformance/releases/download/${version}`; diff --git a/packages/connect-conformance/src/gen/connectrpc/conformance/v1/client_compat_pb.ts b/packages/connect-conformance/src/gen/connectrpc/conformance/v1/client_compat_pb.ts index aff10d9c7..d6c29b0f4 100644 --- a/packages/connect-conformance/src/gen/connectrpc/conformance/v1/client_compat_pb.ts +++ b/packages/connect-conformance/src/gen/connectrpc/conformance/v1/client_compat_pb.ts @@ -1,4 +1,4 @@ -// Copyright 2023 The Connect Authors +// Copyright 2023-2024 The Connect Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -18,7 +18,7 @@ import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; import { Any, Empty, Message, proto3, Struct } from "@bufbuild/protobuf"; -import { Codec, Compression, HTTPVersion, Protocol, StreamType } from "./config_pb.js"; +import { Codec, Compression, HTTPVersion, Protocol, StreamType, TLSCreds } from "./config_pb.js"; import { ConformancePayload, Error, Header, RawHTTPRequest } from "./service_pb.js"; /** @@ -31,36 +31,57 @@ import { ConformancePayload, Error, Header, RawHTTPRequest } from "./service_pb. */ export class ClientCompatRequest extends Message { /** + * The name of the test that this request is performing. + * When writing test cases, this is a required field. + * * @generated from field: string test_name = 1; */ testName = ""; /** + * Test suite YAML definitions should NOT set values for these next + * nine fields (fields 2 - 10). They are automatically populated by the test + * runner. If a test is specific to one of these values, it should instead be + * indicated in the test suite itself (where it defines the required + * features and relevant values for these fields). + * + * The HTTP version to use for the test (i.e. HTTP/1.1, HTTP/2, HTTP/3). + * * @generated from field: connectrpc.conformance.v1.HTTPVersion http_version = 2; */ httpVersion = HTTPVersion.HTTP_VERSION_UNSPECIFIED; /** + * The protocol to use for the test (i.e. Connect, gRPC, gRPC-web). + * * @generated from field: connectrpc.conformance.v1.Protocol protocol = 3; */ protocol = Protocol.UNSPECIFIED; /** + * The codec to use for the test (i.e. JSON, proto/binary). + * * @generated from field: connectrpc.conformance.v1.Codec codec = 4; */ codec = Codec.UNSPECIFIED; /** + * The compression to use for the test (i.e. brotli, gzip, identity). + * * @generated from field: connectrpc.conformance.v1.Compression compression = 5; */ compression = Compression.UNSPECIFIED; /** + * The server host that this request will be sent to. + * * @generated from field: string host = 6; */ host = ""; /** + * The server port that this request will be sent to. + * * @generated from field: uint32 port = 7; */ port = 0; @@ -79,9 +100,9 @@ export class ClientCompatRequest extends Message { * authenticate with the server. This will only be present * when server_tls_cert is non-empty. * - * @generated from field: connectrpc.conformance.v1.ClientCompatRequest.TLSCreds client_tls_creds = 9; + * @generated from field: connectrpc.conformance.v1.TLSCreds client_tls_creds = 9; */ - clientTlsCreds?: ClientCompatRequest_TLSCreds; + clientTlsCreds?: TLSCreds; /** * If non-zero, indicates the maximum size in bytes for a message. @@ -92,16 +113,27 @@ export class ClientCompatRequest extends Message { messageReceiveLimit = 0; /** - * @generated from field: string service = 11; + * The fully-qualified name of the service this test will interact with. + * If specified, method must also be specified. + * If not specified, defaults to "connectrpc.conformance.v1.ConformanceService". + * + * @generated from field: optional string service = 11; */ - service = ""; + service?: string; /** - * @generated from field: string method = 12; + * The method on `service` that will be called. + * If specified, service must also be specified. + * If not specified, the test runner will auto-populate this field based on the stream_type. + * + * @generated from field: optional string method = 12; */ - method = ""; + method?: string; /** + * The stream type of `method` (i.e. Unary, Client-Streaming, Server-Streaming, Full Duplex Bidi, or Half Duplex Bidi). + * When writing test cases, this is a required field. + * * @generated from field: connectrpc.conformance.v1.StreamType stream_type = 13; */ streamType = StreamType.UNSPECIFIED; @@ -116,20 +148,32 @@ export class ClientCompatRequest extends Message { useGetHttpMethod = false; /** + * Any request headers that should be sent as part of the request. + * These include only custom header metadata. Headers that are + * part of the relevant protocol (such as "content-type", etc) should + * not be stated here. + * * @generated from field: repeated connectrpc.conformance.v1.Header request_headers = 15; */ requestHeaders: Header[] = []; /** - * There will be exactly one for unary and server-stream methods. + * The actual request messages that will sent to the server. + * The type URL for all entries should be equal to the request type of the + * method. + * There must be exactly one for unary and server-stream methods but + * can be zero or more for client- and bidi-stream methods. * For client- and bidi-stream methods, all entries will have the - * same type URL (which matches the request type of the method). + * same type URL. * * @generated from field: repeated google.protobuf.Any request_messages = 16; */ requestMessages: Any[] = []; /** + * The timeout, in milliseconds, for the request. This is equivalent to a + * deadline for the request. If unset, there will be no timeout. + * * @generated from field: optional uint32 timeout_ms = 17; */ timeoutMs?: number; @@ -182,10 +226,10 @@ export class ClientCompatRequest extends Message { { no: 6, name: "host", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 7, name: "port", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, { no: 8, name: "server_tls_cert", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, - { no: 9, name: "client_tls_creds", kind: "message", T: ClientCompatRequest_TLSCreds }, + { no: 9, name: "client_tls_creds", kind: "message", T: TLSCreds }, { no: 10, name: "message_receive_limit", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, - { no: 11, name: "service", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 12, name: "method", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 11, name: "service", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, + { no: 12, name: "method", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, { no: 13, name: "stream_type", kind: "enum", T: proto3.getEnumType(StreamType) }, { no: 14, name: "use_get_http_method", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, { no: 15, name: "request_headers", kind: "message", T: Header, repeated: true }, @@ -213,49 +257,6 @@ export class ClientCompatRequest extends Message { } } -/** - * @generated from message connectrpc.conformance.v1.ClientCompatRequest.TLSCreds - */ -export class ClientCompatRequest_TLSCreds extends Message { - /** - * @generated from field: bytes cert = 1; - */ - cert = new Uint8Array(0); - - /** - * @generated from field: bytes key = 2; - */ - key = new Uint8Array(0); - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "connectrpc.conformance.v1.ClientCompatRequest.TLSCreds"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "cert", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, - { no: 2, name: "key", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): ClientCompatRequest_TLSCreds { - return new ClientCompatRequest_TLSCreds().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): ClientCompatRequest_TLSCreds { - return new ClientCompatRequest_TLSCreds().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): ClientCompatRequest_TLSCreds { - return new ClientCompatRequest_TLSCreds().fromJsonString(jsonString, options); - } - - static equals(a: ClientCompatRequest_TLSCreds | PlainMessage | undefined, b: ClientCompatRequest_TLSCreds | PlainMessage | undefined): boolean { - return proto3.util.equals(ClientCompatRequest_TLSCreds, a, b); - } -} - /** * @generated from message connectrpc.conformance.v1.ClientCompatRequest.Cancel */ @@ -339,11 +340,23 @@ export class ClientCompatRequest_Cancel extends Message { /** + * The test name that this response applies to. + * * @generated from field: string test_name = 1; */ testName = ""; /** + * These fields determine the outcome of the request. + * + * With regards to errors, any unexpected errors that prevent the client from + * issuing the RPC and following the instructions implied by the request can + * be reported as an error. These would be errors creating an RPC client from + * the request parameters or unsupported/illegal values in the request + * (e.g. a unary request that defines zero or multiple request messages). + * + * However, once the RPC is issued, any resulting error should instead be encoded in response. + * * @generated from oneof connectrpc.conformance.v1.ClientCompatResponse.result */ result: { @@ -360,16 +373,6 @@ export class ClientCompatResponse extends Message { case: "error"; } | { case: undefined; value?: undefined } = { case: undefined }; - /** - * This field is used only by the reference client, and it can be used - * to provide additional feedback about problems observed in the server - * response. If non-empty, the test case is considered failed even if - * the result above matches all expectations. - * - * @generated from field: repeated string feedback = 4; - */ - feedback: string[] = []; - constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -381,7 +384,6 @@ export class ClientCompatResponse extends Message { { no: 1, name: "test_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 2, name: "response", kind: "message", T: ClientResponseResult, oneof: "result" }, { no: 3, name: "error", kind: "message", T: ClientErrorResult, oneof: "result" }, - { no: 4, name: "feedback", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): ClientCompatResponse { @@ -403,29 +405,40 @@ export class ClientCompatResponse extends Message { /** * The result of a ClientCompatRequest, which may or may not be successful. + * The client will build this message and return it back to the test runner. * * @generated from message connectrpc.conformance.v1.ClientResponseResult */ export class ClientResponseResult extends Message { /** + * All response headers read from the response. + * * @generated from field: repeated connectrpc.conformance.v1.Header response_headers = 1; */ responseHeaders: Header[] = []; /** + * Servers should echo back payloads that they received as part of the request. + * This field should contain all the payloads the server echoed back. Note that + * There will be zero-to-one for unary and client-stream methods and + * zero-to-many for server- and bidi-stream methods. + * * @generated from field: repeated connectrpc.conformance.v1.ConformancePayload payloads = 2; */ payloads: ConformancePayload[] = []; /** * The error received from the actual RPC invocation. Note this is not representative - * of a runtime error and should always be the proto equivalent of a Connect error. + * of a runtime error and should always be the proto equivalent of a Connect + * or gRPC error. * * @generated from field: connectrpc.conformance.v1.Error error = 3; */ error?: Error; /** + * All response headers read from the response. + * * @generated from field: repeated connectrpc.conformance.v1.Header response_trailers = 4; */ responseTrailers: Header[] = []; @@ -439,28 +452,26 @@ export class ClientResponseResult extends Message { numUnsentRequests = 0; /** - * The HTTP status code of the response. - * - * @generated from field: int32 actual_status_code = 6; - */ - actualStatusCode = 0; - - /** - * When processing an error from a Connect server, this should contain - * the actual JSON received on the wire. + * The following field is only set by the reference client. It communicates + * the underlying HTTP status code of the server's response. + * If you are implementing a client-under-test, you should ignore this field + * and leave it unset. * - * @generated from field: google.protobuf.Struct connect_error_raw = 7; + * @generated from field: optional int32 http_status_code = 6; */ - connectErrorRaw?: Struct; + httpStatusCode?: number; /** - * Any HTTP trailers observed after the response body. These do NOT - * include trailers that conveyed via the body, as done in the gRPC-Web - * and Connect streaming protocols. + * This field is used only by the reference client, and it can be used + * to provide additional feedback about problems observed in the server + * response or in client processing of the response. If non-empty, the test + * case is considered failed even if the result above matches all expectations. + * If you are implementing a client-under-test, you should ignore this field + * and leave it unset. * - * @generated from field: repeated connectrpc.conformance.v1.Header actual_http_trailers = 8; + * @generated from field: repeated string feedback = 7; */ - actualHttpTrailers: Header[] = []; + feedback: string[] = []; constructor(data?: PartialMessage) { super(); @@ -475,9 +486,8 @@ export class ClientResponseResult extends Message { { no: 3, name: "error", kind: "message", T: Error }, { no: 4, name: "response_trailers", kind: "message", T: Header, repeated: true }, { no: 5, name: "num_unsent_requests", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, - { no: 6, name: "actual_status_code", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, - { no: 7, name: "connect_error_raw", kind: "message", T: Struct }, - { no: 8, name: "actual_http_trailers", kind: "message", T: Header, repeated: true }, + { no: 6, name: "http_status_code", kind: "scalar", T: 5 /* ScalarType.INT32 */, opt: true }, + { no: 7, name: "feedback", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): ClientResponseResult { @@ -506,6 +516,10 @@ export class ClientResponseResult extends Message { */ export class ClientErrorResult extends Message { /** + * A message describing the error that occurred. This string will be shown to + * users running conformance tests so it should include any relevant details + * that may help troubleshoot or remedy the error. + * * @generated from field: string message = 1; */ message = ""; @@ -538,3 +552,79 @@ export class ClientErrorResult extends Message { } } +/** + * Details about various values as observed on the wire. This message is used + * only by the reference client when reporting results and should not be populated + * by clients under test. + * + * @generated from message connectrpc.conformance.v1.WireDetails + */ +export class WireDetails extends Message { + /** + * The HTTP status code of the response. + * + * @generated from field: int32 actual_status_code = 1; + */ + actualStatusCode = 0; + + /** + * When processing an error from a Connect server, this should contain + * the actual JSON received on the wire. + * + * @generated from field: google.protobuf.Struct connect_error_raw = 2; + */ + connectErrorRaw?: Struct; + + /** + * Any HTTP trailers observed after the response body. These do NOT + * include trailers that conveyed via the body, as done in the gRPC-Web + * and Connect streaming protocols. + * + * @generated from field: repeated connectrpc.conformance.v1.Header actual_http_trailers = 3; + */ + actualHttpTrailers: Header[] = []; + + /** + * Any trailers that were transmitted in the final message of the + * response body for a gRPC-Web response. This could differ from the + * ClientResponseResult.response_trailers field since the RPC client + * library might canonicalize keys and it might choose to remove + * "grpc-status" et al from the set of metadata. This field will + * capture all of the entries and their exact on-the-wire spelling + * and formatting. + * + * @generated from field: optional string actual_grpcweb_trailers = 4; + */ + actualGrpcwebTrailers?: string; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "connectrpc.conformance.v1.WireDetails"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "actual_status_code", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 2, name: "connect_error_raw", kind: "message", T: Struct }, + { no: 3, name: "actual_http_trailers", kind: "message", T: Header, repeated: true }, + { no: 4, name: "actual_grpcweb_trailers", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): WireDetails { + return new WireDetails().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): WireDetails { + return new WireDetails().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): WireDetails { + return new WireDetails().fromJsonString(jsonString, options); + } + + static equals(a: WireDetails | PlainMessage | undefined, b: WireDetails | PlainMessage | undefined): boolean { + return proto3.util.equals(WireDetails, a, b); + } +} + diff --git a/packages/connect-conformance/src/gen/connectrpc/conformance/v1/config_pb.ts b/packages/connect-conformance/src/gen/connectrpc/conformance/v1/config_pb.ts index b531c08a2..1eebb2f89 100644 --- a/packages/connect-conformance/src/gen/connectrpc/conformance/v1/config_pb.ts +++ b/packages/connect-conformance/src/gen/connectrpc/conformance/v1/config_pb.ts @@ -1,4 +1,4 @@ -// Copyright 2023 The Connect Authors +// Copyright 2023-2024 The Connect Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -213,6 +213,116 @@ proto3.util.setEnumType(StreamType, "connectrpc.conformance.v1.StreamType", [ { no: 5, name: "STREAM_TYPE_FULL_DUPLEX_BIDI_STREAM" }, ]); +/** + * @generated from enum connectrpc.conformance.v1.Code + */ +export enum Code { + /** + * @generated from enum value: CODE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: CODE_CANCELED = 1; + */ + CANCELED = 1, + + /** + * @generated from enum value: CODE_UNKNOWN = 2; + */ + UNKNOWN = 2, + + /** + * @generated from enum value: CODE_INVALID_ARGUMENT = 3; + */ + INVALID_ARGUMENT = 3, + + /** + * @generated from enum value: CODE_DEADLINE_EXCEEDED = 4; + */ + DEADLINE_EXCEEDED = 4, + + /** + * @generated from enum value: CODE_NOT_FOUND = 5; + */ + NOT_FOUND = 5, + + /** + * @generated from enum value: CODE_ALREADY_EXISTS = 6; + */ + ALREADY_EXISTS = 6, + + /** + * @generated from enum value: CODE_PERMISSION_DENIED = 7; + */ + PERMISSION_DENIED = 7, + + /** + * @generated from enum value: CODE_RESOURCE_EXHAUSTED = 8; + */ + RESOURCE_EXHAUSTED = 8, + + /** + * @generated from enum value: CODE_FAILED_PRECONDITION = 9; + */ + FAILED_PRECONDITION = 9, + + /** + * @generated from enum value: CODE_ABORTED = 10; + */ + ABORTED = 10, + + /** + * @generated from enum value: CODE_OUT_OF_RANGE = 11; + */ + OUT_OF_RANGE = 11, + + /** + * @generated from enum value: CODE_UNIMPLEMENTED = 12; + */ + UNIMPLEMENTED = 12, + + /** + * @generated from enum value: CODE_INTERNAL = 13; + */ + INTERNAL = 13, + + /** + * @generated from enum value: CODE_UNAVAILABLE = 14; + */ + UNAVAILABLE = 14, + + /** + * @generated from enum value: CODE_DATA_LOSS = 15; + */ + DATA_LOSS = 15, + + /** + * @generated from enum value: CODE_UNAUTHENTICATED = 16; + */ + UNAUTHENTICATED = 16, +} +// Retrieve enum metadata with: proto3.getEnumType(Code) +proto3.util.setEnumType(Code, "connectrpc.conformance.v1.Code", [ + { no: 0, name: "CODE_UNSPECIFIED" }, + { no: 1, name: "CODE_CANCELED" }, + { no: 2, name: "CODE_UNKNOWN" }, + { no: 3, name: "CODE_INVALID_ARGUMENT" }, + { no: 4, name: "CODE_DEADLINE_EXCEEDED" }, + { no: 5, name: "CODE_NOT_FOUND" }, + { no: 6, name: "CODE_ALREADY_EXISTS" }, + { no: 7, name: "CODE_PERMISSION_DENIED" }, + { no: 8, name: "CODE_RESOURCE_EXHAUSTED" }, + { no: 9, name: "CODE_FAILED_PRECONDITION" }, + { no: 10, name: "CODE_ABORTED" }, + { no: 11, name: "CODE_OUT_OF_RANGE" }, + { no: 12, name: "CODE_UNIMPLEMENTED" }, + { no: 13, name: "CODE_INTERNAL" }, + { no: 14, name: "CODE_UNAVAILABLE" }, + { no: 15, name: "CODE_DATA_LOSS" }, + { no: 16, name: "CODE_UNAUTHENTICATED" }, +]); + /** * Config defines the configuration for running conformance tests. * This enumerates all of the "flavors" of the test suite to run. @@ -277,6 +387,11 @@ export class Config extends Message { } /** + * Features define the feature set that a client or server supports. They are + * used to determine the server configurations and test cases that + * will be run. They are defined in YAML files and are specified as part of the + * --conf flag to the test runner. + * * TODO: we could probably model some of the constraints on what are valid vs. * invalid (i.e. conflicting/impossible) features using protovalidate rules * @@ -284,6 +399,7 @@ export class Config extends Message { */ export class Features extends Message { /** + * Supported HTTP versions. * If empty, HTTP 1.1 and HTTP/2 are assumed. * * @generated from field: repeated connectrpc.conformance.v1.HTTPVersion versions = 1; @@ -291,6 +407,7 @@ export class Features extends Message { versions: HTTPVersion[] = []; /** + * Supported protocols. * If empty, all three are assumed: Connect, gRPC, and gRPC-Web. * * @generated from field: repeated connectrpc.conformance.v1.Protocol protocols = 2; @@ -298,6 +415,7 @@ export class Features extends Message { protocols: Protocol[] = []; /** + * Supported codecs. * If empty, "proto" and "json" are assumed. * * @generated from field: repeated connectrpc.conformance.v1.Codec codecs = 3; @@ -305,6 +423,7 @@ export class Features extends Message { codecs: Codec[] = []; /** + * Supported compression algorithms. * If empty, "identity" and "gzip" are assumed. * * @generated from field: repeated connectrpc.conformance.v1.Compression compressions = 4; @@ -312,6 +431,7 @@ export class Features extends Message { compressions: Compression[] = []; /** + * Supported stream types. * If empty, all stream types are assumed. This is usually for * clients, since some client environments may not be able to * support certain kinds of streaming operations, especially @@ -322,6 +442,7 @@ export class Features extends Message { streamTypes: StreamType[] = []; /** + * Whether H2C (unencrypted, non-TLS HTTP/2 over cleartext) is supported. * If absent, true is assumed. * * @generated from field: optional bool supports_h2c = 6; @@ -329,6 +450,7 @@ export class Features extends Message { supportsH2c?: boolean; /** + * Whether TLS is supported. * If absent, true is assumed. * * @generated from field: optional bool supports_tls = 7; @@ -336,6 +458,7 @@ export class Features extends Message { supportsTls?: boolean; /** + * Whether the client supports TLS certificates. * If absent, false is assumed. This should not be set if * supports_tls is false. * @@ -344,6 +467,7 @@ export class Features extends Message { supportsTlsClientCerts?: boolean; /** + * Whether trailers are supported. * If absent, true is assumed. If false, implies that gRPC protocol is not allowed. * * @generated from field: optional bool supports_trailers = 9; @@ -351,6 +475,7 @@ export class Features extends Message { supportsTrailers?: boolean; /** + * Whether half duplex bidi streams are supported over HTTP/1.1. * If absent, false is assumed. * * @generated from field: optional bool supports_half_duplex_bidi_over_http1 = 10; @@ -358,6 +483,7 @@ export class Features extends Message { supportsHalfDuplexBidiOverHttp1?: boolean; /** + * Whether Connect via GET is supported. * If absent, true is assumed. * * @generated from field: optional bool supports_connect_get = 11; @@ -365,16 +491,10 @@ export class Features extends Message { supportsConnectGet?: boolean; /** - * If absent, false is assumed. - * - * @generated from field: optional bool requires_connect_version_header = 12; - */ - requiresConnectVersionHeader?: boolean; - - /** + * Whether a message receive limit is supported. * If absent, true is assumed. * - * @generated from field: optional bool supports_message_receive_limit = 13; + * @generated from field: optional bool supports_message_receive_limit = 12; */ supportsMessageReceiveLimit?: boolean; @@ -397,8 +517,7 @@ export class Features extends Message { { no: 9, name: "supports_trailers", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, { no: 10, name: "supports_half_duplex_bidi_over_http1", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, { no: 11, name: "supports_connect_get", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, - { no: 12, name: "requires_connect_version_header", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, - { no: 13, name: "supports_message_receive_limit", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, + { no: 12, name: "supports_message_receive_limit", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): Features { @@ -419,6 +538,11 @@ export class Features extends Message { } /** + * ConfigCase represents a single resolved configuration case. When tests are + * run, the Config and the supported features therein are used to compute all + * of the cases relevant to the implementation under test. These configuration + * cases are then used to select which test cases are applicable. + * * TODO: we could probably model some of the constraints on what is a valid * vs. invalid config case using protovalidate rules * @@ -520,3 +644,50 @@ export class ConfigCase extends Message { } } +/** + * TLSCreds represents credentials for TLS. It includes both a + * certificate and corresponding private key. Both are encoded + * in PEM format. + * + * @generated from message connectrpc.conformance.v1.TLSCreds + */ +export class TLSCreds extends Message { + /** + * @generated from field: bytes cert = 1; + */ + cert = new Uint8Array(0); + + /** + * @generated from field: bytes key = 2; + */ + key = new Uint8Array(0); + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "connectrpc.conformance.v1.TLSCreds"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "cert", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, + { no: 2, name: "key", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): TLSCreds { + return new TLSCreds().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): TLSCreds { + return new TLSCreds().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): TLSCreds { + return new TLSCreds().fromJsonString(jsonString, options); + } + + static equals(a: TLSCreds | PlainMessage | undefined, b: TLSCreds | PlainMessage | undefined): boolean { + return proto3.util.equals(TLSCreds, a, b); + } +} + diff --git a/packages/connect-conformance/src/gen/connectrpc/conformance/v1/server_compat_pb.ts b/packages/connect-conformance/src/gen/connectrpc/conformance/v1/server_compat_pb.ts index 31abd7261..988069651 100644 --- a/packages/connect-conformance/src/gen/connectrpc/conformance/v1/server_compat_pb.ts +++ b/packages/connect-conformance/src/gen/connectrpc/conformance/v1/server_compat_pb.ts @@ -1,4 +1,4 @@ -// Copyright 2023 The Connect Authors +// Copyright 2023-2024 The Connect Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -18,7 +18,7 @@ import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; import { Message, proto3 } from "@bufbuild/protobuf"; -import { HTTPVersion, Protocol } from "./config_pb.js"; +import { HTTPVersion, Protocol, TLSCreds } from "./config_pb.js"; /** * Describes one configuration for an RPC server. The server is @@ -40,18 +40,33 @@ import { HTTPVersion, Protocol } from "./config_pb.js"; */ export class ServerCompatRequest extends Message { /** + * Signals to the server that it must support at least this protocol. Note + * that it is fine to support others. + * For example if `PROTOCOL_CONNECT` is specified, the server _must_ support + * at least Connect, but _may_ also support gRPC or gRPC-web. + * * @generated from field: connectrpc.conformance.v1.Protocol protocol = 1; */ protocol = Protocol.UNSPECIFIED; /** + * Signals to the server the minimum HTTP version to support. As with + * `protocol`, it is fine to support other versions. For example, if + * `HTTP_VERSION_2` is specified, the server _must_ support HTTP/2, but _may_ also + * support HTTP/1.1 or HTTP/3. + * * @generated from field: connectrpc.conformance.v1.HTTPVersion http_version = 2; */ httpVersion = HTTPVersion.HTTP_VERSION_UNSPECIFIED; /** - * if true, generate a self-signed cert and include it in the - * ServerCompatResponse along with the actual port + * If true, generate a certificate that clients will be configured to trust + * when connecting and return it in the `pem_cert` field of the `ServerCompatResponse`. + * The certificate can be any TLS certificate where the subject matches the + * value sent back in the `host` field of the `ServerCompatResponse`. + * Self-signed certificates (and `localhost` as the subject) are allowed. + * If false, the server should not use TLS and instead use + * a plain-text/unencrypted socket. * * @generated from field: bool use_tls = 4; */ @@ -78,6 +93,28 @@ export class ServerCompatRequest extends Message { */ messageReceiveLimit = 0; + /** + * If use_tls is true, this provides details for a self-signed TLS + * cert that the server may use. + * + * The provided certificate is only good for loopback communication: + * it uses "localhost" and "127.0.0.1" as the IP and DNS names in + * the certificate's subject. If the server needs a different subject + * or the client is in an environment where configuring trust of a + * self-signed certificate is difficult or infeasible. + * + * If the server implementation chooses to use these credentials, + * it must echo back the certificate in the ServerCompatResponse and + * should also leave the host field empty or explicitly set to + * "127.0.0.1". + * + * If it chooses to use a different certificate and key, it must send + * back the corresponding certificate in the ServerCompatResponse. + * + * @generated from field: connectrpc.conformance.v1.TLSCreds server_creds = 7; + */ + serverCreds?: TLSCreds; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -91,6 +128,7 @@ export class ServerCompatRequest extends Message { { no: 4, name: "use_tls", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, { no: 5, name: "client_tls_cert", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, { no: 6, name: "message_receive_limit", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 7, name: "server_creds", kind: "message", T: TLSCreds }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): ServerCompatRequest { @@ -117,18 +155,25 @@ export class ServerCompatRequest extends Message { */ export class ServerCompatResponse extends Message { /** + * The host where the server is running. This should usually be `127.0.0.1`, + * unless your program actually starts a remote server to which the client + * should connect. + * * @generated from field: string host = 1; */ host = ""; /** + * The port where the server is listening. + * * @generated from field: uint32 port = 2; */ port = 0; /** - * The server's PEM-encoded certificate, so the - * client can verify it when connecting via TLS. + * The TLS certificate, in PEM format, if `use_tls` was set + * to `true`. Clients will verify this certificate when connecting via TLS. + * If `use_tls` was set to `false`, this should always be empty. * * @generated from field: bytes pem_cert = 3; */ @@ -164,43 +209,3 @@ export class ServerCompatResponse extends Message { } } -/** - * The server doesn't support the requested protocol, or had a runtime error - * while starting up. - * - * @generated from message connectrpc.conformance.v1.ServerErrorResult - */ -export class ServerErrorResult extends Message { - /** - * @generated from field: string message = 1; - */ - message = ""; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "connectrpc.conformance.v1.ServerErrorResult"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): ServerErrorResult { - return new ServerErrorResult().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): ServerErrorResult { - return new ServerErrorResult().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): ServerErrorResult { - return new ServerErrorResult().fromJsonString(jsonString, options); - } - - static equals(a: ServerErrorResult | PlainMessage | undefined, b: ServerErrorResult | PlainMessage | undefined): boolean { - return proto3.util.equals(ServerErrorResult, a, b); - } -} - diff --git a/packages/connect-conformance/src/gen/connectrpc/conformance/v1/service_connect.ts b/packages/connect-conformance/src/gen/connectrpc/conformance/v1/service_connect.ts index 29538a3ac..9c46f51b4 100644 --- a/packages/connect-conformance/src/gen/connectrpc/conformance/v1/service_connect.ts +++ b/packages/connect-conformance/src/gen/connectrpc/conformance/v1/service_connect.ts @@ -1,4 +1,4 @@ -// Copyright 2023 The Connect Authors +// Copyright 2023-2024 The Connect Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -26,19 +26,19 @@ import { MethodIdempotency, MethodKind } from "@bufbuild/protobuf"; * * Test servers must implement the service as described. * - * A unary operation. The request indicates the response headers and trailers - * and also indicates either a response message or an error to send back. - * - * Response message data is specified as bytes. The service should echo back - * request properties in the ConformancePayload and then include the message - * data in the data field. - * * @generated from service connectrpc.conformance.v1.ConformanceService */ export const ConformanceService = { typeName: "connectrpc.conformance.v1.ConformanceService", methods: { /** + * A unary operation. The request indicates the response headers and trailers + * and also indicates either a response message or an error to send back. + * + * Response message data is specified as bytes. The service should echo back + * request properties in the ConformancePayload and then include the message + * data in the data field. + * * If the response_delay_ms duration is specified, the server should wait the * given duration after reading the request before sending the corresponding * response. diff --git a/packages/connect-conformance/src/gen/connectrpc/conformance/v1/service_pb.ts b/packages/connect-conformance/src/gen/connectrpc/conformance/v1/service_pb.ts index c5e6421e6..c0232d537 100644 --- a/packages/connect-conformance/src/gen/connectrpc/conformance/v1/service_pb.ts +++ b/packages/connect-conformance/src/gen/connectrpc/conformance/v1/service_pb.ts @@ -1,4 +1,4 @@ -// Copyright 2023 The Connect Authors +// Copyright 2023-2024 The Connect Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -18,7 +18,7 @@ import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; import { Any, Message, proto3 } from "@bufbuild/protobuf"; -import { Compression } from "./config_pb.js"; +import { Code, Compression } from "./config_pb.js"; /** * A definition of a response to be sent from a single-response endpoint. @@ -727,6 +727,9 @@ export class UnimplementedResponse extends Message { */ export class ConformancePayload extends Message { /** + * Any response data specified in the response definition to the server should be + * echoed back here. + * * @generated from field: bytes data = 1; */ data = new Uint8Array(0); @@ -847,6 +850,8 @@ export class ConformancePayload_RequestInfo extends Message { /** + * The query params observed in the request URL. + * * @generated from field: repeated connectrpc.conformance.v1.Header query_params = 1; */ queryParams: Header[] = []; @@ -886,9 +891,12 @@ export class ConformancePayload_ConnectGetInfo extends Message { /** - * @generated from field: int32 code = 1; + * The error code. + * For a list of Connect error codes see: https://connectrpc.com/docs/protocol#error-codes + * + * @generated from field: connectrpc.conformance.v1.Code code = 1; */ - code = 0; + code = Code.UNSPECIFIED; /** * If this value is absent in a test case response definition, the contents of the @@ -901,6 +909,9 @@ export class Error extends Message { message?: string; /** + * Errors in Connect and gRPC protocols can have arbitrary messages + * attached to them, which are known as error details. + * * @generated from field: repeated google.protobuf.Any details = 3; */ details: Any[] = []; @@ -913,7 +924,7 @@ export class Error extends Message { static readonly runtime: typeof proto3 = proto3; static readonly typeName = "connectrpc.conformance.v1.Error"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "code", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 1, name: "code", kind: "enum", T: proto3.getEnumType(Code) }, { no: 2, name: "message", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, { no: 3, name: "details", kind: "message", T: Any, repeated: true }, ]); @@ -942,11 +953,17 @@ export class Error extends Message { */ export class Header extends Message
{ /** + * Header/trailer name (key). + * * @generated from field: string name = 1; */ name = ""; /** + * Header/trailer value. This is repeated to explicitly support headers and + * trailers where a key is repeated. In such a case, these values must be in + * the same order as which values appeared in the header or trailer block. + * * @generated from field: repeated string value = 2; */ value: string[] = []; @@ -989,16 +1006,22 @@ export class Header extends Message
{ */ export class RawHTTPRequest extends Message { /** + * The HTTP verb (i.e. GET , POST). + * * @generated from field: string verb = 1; */ verb = ""; /** + * The URI to send the request to. + * * @generated from field: string uri = 2; */ uri = ""; /** + * Any headers to set on the request. + * * @generated from field: repeated connectrpc.conformance.v1.Header headers = 3; */ headers: Header[] = []; @@ -1012,6 +1035,9 @@ export class RawHTTPRequest extends Message { rawQueryParams: Header[] = []; /** + * This provides an easier way to define a complex binary query param + * than having to write literal base64-encoded bytes in raw_query_params. + * * @generated from field: repeated connectrpc.conformance.v1.RawHTTPRequest.EncodedQueryParam encoded_query_params = 5; */ encodedQueryParams: RawHTTPRequest_EncodedQueryParam[] = []; @@ -1073,18 +1099,19 @@ export class RawHTTPRequest extends Message { } /** - * This provides an easier way to define a complex binary query param - * than having to write literal base64-encoded bytes in raw_query_params. - * * @generated from message connectrpc.conformance.v1.RawHTTPRequest.EncodedQueryParam */ export class RawHTTPRequest_EncodedQueryParam extends Message { /** + * Query param name. + * * @generated from field: string name = 1; */ name = ""; /** + * Query param value. + * * @generated from field: connectrpc.conformance.v1.MessageContents value = 2; */ value?: MessageContents; @@ -1212,6 +1239,8 @@ export class MessageContents extends Message { */ export class StreamContents extends Message { /** + * The messages in the stream. + * * @generated from field: repeated connectrpc.conformance.v1.StreamContents.StreamItem items = 1; */ items: StreamContents_StreamItem[] = []; @@ -1306,11 +1335,15 @@ export class StreamContents_StreamItem extends Message { /** + * If status code is not specified, it will default to a 200 response code. + * * @generated from field: uint32 status_code = 1; */ statusCode = 0; /** + * Headers to be set on the response. + * * @generated from field: repeated connectrpc.conformance.v1.Header headers = 2; */ headers: Header[] = []; @@ -1338,6 +1371,8 @@ export class RawHTTPResponse extends Message { } | { case: undefined; value?: undefined } = { case: undefined }; /** + * Trailers to be set on the response. + * * @generated from field: repeated connectrpc.conformance.v1.Header trailers = 5; */ trailers: Header[] = []; diff --git a/packages/connect-conformance/src/gen/connectrpc/conformance/v1/suite_pb.ts b/packages/connect-conformance/src/gen/connectrpc/conformance/v1/suite_pb.ts index b58063ab2..cf799a625 100644 --- a/packages/connect-conformance/src/gen/connectrpc/conformance/v1/suite_pb.ts +++ b/packages/connect-conformance/src/gen/connectrpc/conformance/v1/suite_pb.ts @@ -1,4 +1,4 @@ -// Copyright 2023 The Connect Authors +// Copyright 2023-2024 The Connect Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -18,7 +18,7 @@ import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; import { Message, proto3 } from "@bufbuild/protobuf"; -import { Codec, Compression, HTTPVersion, Protocol } from "./config_pb.js"; +import { Code, Codec, Compression, HTTPVersion, Protocol } from "./config_pb.js"; import { ClientCompatRequest, ClientResponseResult } from "./client_compat_pb.js"; /** @@ -32,16 +32,26 @@ import { ClientCompatRequest, ClientResponseResult } from "./client_compat_pb.js */ export class TestSuite extends Message { /** + * Test suite name. When writing test suites, this is a required field. + * * @generated from field: string name = 1; */ name = ""; /** + * The mode (client or server) that this test suite applies to. This is used + * in conjunction with the `--mode` flag passed to the conformance runner + * binary. If the mode on the suite is set to client, the tests will only be + * run if `--mode client` is set on the command to the test runner. + * Likewise if mode is server. If this is unset, the test case will be run in both modes. + * * @generated from field: connectrpc.conformance.v1.TestSuite.TestMode mode = 2; */ mode = TestSuite_TestMode.UNSPECIFIED; /** + * The actual test cases in the suite. + * * @generated from field: repeated connectrpc.conformance.v1.TestCase test_cases = 3; */ testCases: TestCase[] = []; @@ -87,7 +97,8 @@ export class TestSuite extends Message { connectVersionMode = TestSuite_ConnectVersionMode.UNSPECIFIED; /** - * If true, the cases in this suite rely on TLS. + * If true, the cases in this suite rely on TLS and will only be run against + * TLS server configurations. * * @generated from field: bool relies_on_tls = 9; */ @@ -266,12 +277,41 @@ export class TestCase extends Message { expandRequests: TestCase_ExpandedSize[] = []; /** - * Defines the expected response to the above RPC. Many + * Defines the expected response to the above RPC. The expected response for + * a test is auto-generated based on the request details. The conformance runner + * will determine what the response should be according to the values specified + * in the test suite and individual test cases. + * + * This value can also be specified explicitly in the test case YAML. However, + * this is typically only needed for exception test cases. If the expected + * response is mostly re-stating the response definition that appears in the + * requests, test cases should rely on the auto-generation if possible. + * Otherwise, specifying an expected response can make the test YAML overly + * verbose and harder to read, write, and maintain. + * + * If the test induces behavior that prevents the server from sending or client + * from receiving the full response definition, it will be necessary to define + * the expected response explicitly. Timeouts, cancellations, and exceeding + * message size limits are good examples of this. + * + * Specifying an expected response explicitly in test definitions will override + * the auto-generation of the test runner. * * @generated from field: connectrpc.conformance.v1.ClientResponseResult expected_response = 3; */ expectedResponse?: ClientResponseResult; + /** + * When expected_response indicates that an error is expected, in some cases, the + * actual error code returned may be flexible. In that case, this field provides + * other acceptable error codes, in addition to the one indicated in the + * expected_response. As long as the actual error's code matches any of these, the + * error is considered conformant, and the test case can pass. + * + * @generated from field: repeated connectrpc.conformance.v1.Code other_allowed_error_codes = 4; + */ + otherAllowedErrorCodes: Code[] = []; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -283,6 +323,7 @@ export class TestCase extends Message { { no: 1, name: "request", kind: "message", T: ClientCompatRequest }, { no: 2, name: "expand_requests", kind: "message", T: TestCase_ExpandedSize, repeated: true }, { no: 3, name: "expected_response", kind: "message", T: ClientResponseResult }, + { no: 4, name: "other_allowed_error_codes", kind: "enum", T: proto3.getEnumType(Code), repeated: true }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): TestCase { @@ -307,6 +348,11 @@ export class TestCase extends Message { */ export class TestCase_ExpandedSize extends Message { /** + * The size, in bytes, relative to the limit. For example, to expand to a + * size that is exactly equal to the limit, this should be set to zero. + * Any value greater than zero indicates that the request size will be that + * many bytes over the limit. + * * @generated from field: optional int32 size_relative_to_limit = 1; */ sizeRelativeToLimit?: number; diff --git a/packages/connect-conformance/src/node/server.ts b/packages/connect-conformance/src/node/server.ts index 98c9a5e3f..bb432a695 100644 --- a/packages/connect-conformance/src/node/server.ts +++ b/packages/connect-conformance/src/node/server.ts @@ -36,7 +36,6 @@ import { ServerStreamRequest, UnaryRequest, } from "../gen/connectrpc/conformance/v1/service_pb.js"; -import { createCert } from "../tls.js"; export function run() { const req = ServerCompatRequest.fromBinary( @@ -67,8 +66,11 @@ export function run() { rejectUnauthorized?: true; highWaterMark?: number; } = {}; - if (req.useTls) { - serverOptions = createCert(); + if (req.useTls && req.serverCreds !== undefined) { + serverOptions = { + key: req.serverCreds.key.toString(), + cert: req.serverCreds.cert.toString(), + }; if (req.clientTlsCert.length > 0) { serverOptions = { ...serverOptions, diff --git a/packages/connect-conformance/src/protocol.ts b/packages/connect-conformance/src/protocol.ts index 95995e775..b6cd98538 100644 --- a/packages/connect-conformance/src/protocol.ts +++ b/packages/connect-conformance/src/protocol.ts @@ -12,12 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ConnectError } from "@connectrpc/connect"; +import { ConnectError, Code } from "@connectrpc/connect"; import { Error as ConformanceError, Header as ConformanceHeader, ConformancePayload_RequestInfo, } from "./gen/connectrpc/conformance/v1/service_pb.js"; +import { Code as ConformanceCode } from "./gen/connectrpc/conformance/v1/config_pb.js"; import { createRegistry, Any, Message } from "@bufbuild/protobuf"; const detailsRegitry = createRegistry(ConformancePayload_RequestInfo); @@ -28,7 +29,7 @@ export function connectErrorFromProto(err: ConformanceError) { // We need to unpack the Any messages for connect to represent them accurately. return new ConnectError( err.message ?? "", - err.code, + err.code as unknown as Code, undefined, err.details.map((d) => { const m = d.unpack(detailsRegitry); @@ -58,7 +59,7 @@ export function convertToProtoError(err: ConnectError | undefined) { } } return new ConformanceError({ - code: err.code, + code: err.code as unknown as ConformanceCode, message: err.rawMessage, details, }); diff --git a/packages/connect-conformance/src/tls.ts b/packages/connect-conformance/src/tls.ts deleted file mode 100644 index 19c838518..000000000 --- a/packages/connect-conformance/src/tls.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright 2021-2024 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import * as forge from "node-forge"; -import { Integer } from "asn1js"; - -export function createCert() { - const keys = forge.pki.rsa.generateKeyPair(2048); - const cert = forge.pki.createCertificate(); - cert.publicKey = keys.publicKey; - cert.serialNumber = new Integer({ - value: Math.floor(Math.random() * Number.MIN_SAFE_INTEGER), - }).toString("hex"); - cert.setSubject([ - { - name: "organizationName", - value: "ConnectRPC", - }, - { - name: "commonName", - value: "Conformance Server", - }, - ]); - const now = new Date(); - const notBefore = new Date(); - notBefore.setDate(now.getDate() - 1); - const notAfter = new Date(); - notAfter.setDate(now.getDate() + 7); - cert.validity.notBefore = notBefore; - cert.validity.notAfter = notAfter; - cert.setExtensions([ - { - name: "basicConstraints", - cA: true, - }, - { - name: "keyUsage", - digitalSignature: true, - keyEncipherment: true, - }, - { - name: "extKeyUsage", - serverAuth: true, - }, - { - name: "subjectAltName", - altNames: [ - { - type: 2, // DNS - value: "localhost", - }, - { - type: 7, // IP - ip: "127.0.0.1", - }, - { - type: 7, // IP - ip: "::1", - }, - ], - }, - ]); - cert.sign(keys.privateKey, forge.md.sha256.create()); - return { - cert: forge.pki.certificateToPem(cert), - key: forge.pki.privateKeyToPem(keys.privateKey), - }; -} diff --git a/packages/connect-node-test/jasmine.json b/packages/connect-node-test/jasmine.json index 200383be8..c569515cb 100644 --- a/packages/connect-node-test/jasmine.json +++ b/packages/connect-node-test/jasmine.json @@ -1,6 +1,6 @@ { "spec_dir": "dist/esm", - "spec_files": ["**/*.spec.js"], + "spec_files": ["**/*.spec.js", "!**/conformance/*.spec.js"], "helpers": ["helpers/**/*.js"], "env": { "stopSpecOnExpectationFailure": false, diff --git a/packages/connect-web-bench/README.md b/packages/connect-web-bench/README.md index cca7964d0..433445e64 100644 --- a/packages/connect-web-bench/README.md +++ b/packages/connect-web-bench/README.md @@ -10,5 +10,5 @@ it like a web server would usually do. | code generator | bundle size | minified | compressed | |----------------|-------------------:|-----------------------:|---------------------:| -| connect | 123,614 b | 54,232 b | 14,607 b | +| connect | 124,573 b | 54,640 b | 14,723 b | | grpc-web | 415,212 b | 300,936 b | 53,420 b | diff --git a/packages/connect-web-test/src/conformance/fail_server_streaming.spec.ts b/packages/connect-web-test/src/conformance/fail_server_streaming.spec.ts index 7612f990d..93064225d 100644 --- a/packages/connect-web-test/src/conformance/fail_server_streaming.spec.ts +++ b/packages/connect-web-test/src/conformance/fail_server_streaming.spec.ts @@ -51,7 +51,7 @@ describe("fail_server_streaming", () => { expectError(e); } }); - it("with callback client", function (done) { + xit("with callback client", function (done) { const client = createCallbackClient(TestService, transport()); client.failStreamingOutputCall( request, diff --git a/packages/connect-web-test/src/conformance/unimplemented_server_streaming_method.spec.ts b/packages/connect-web-test/src/conformance/unimplemented_server_streaming_method.spec.ts index 86484b708..9c576a221 100644 --- a/packages/connect-web-test/src/conformance/unimplemented_server_streaming_method.spec.ts +++ b/packages/connect-web-test/src/conformance/unimplemented_server_streaming_method.spec.ts @@ -22,7 +22,7 @@ import { TestService } from "../gen/connectrpc/conformance/v1/test_connect.js"; import { describeTransports } from "../helpers/conformanceserver.js"; import { Empty } from "@bufbuild/protobuf"; -describe("unimplemented_server_streaming_method", function () { +xdescribe("unimplemented_server_streaming_method", function () { function expectError(err: unknown) { expect(err).toBeInstanceOf(ConnectError); if (err instanceof ConnectError) { diff --git a/packages/connect-web/src/connect-transport.ts b/packages/connect-web/src/connect-transport.ts index 76385e124..3e27ce6ec 100644 --- a/packages/connect-web/src/connect-transport.ts +++ b/packages/connect-web/src/connect-transport.ts @@ -32,7 +32,12 @@ import type { UnaryResponse, ContextValues, } from "@connectrpc/connect"; -import { appendHeaders, createContextValues } from "@connectrpc/connect"; +import { + Code, + ConnectError, + appendHeaders, + createContextValues, +} from "@connectrpc/connect"; import { createClientMethodSerializers, createEnvelopeReadableStream, @@ -41,6 +46,7 @@ import { encodeEnvelope, runStreamingCall, runUnaryCall, + compressedFlag, } from "@connectrpc/connect/protocol"; import { endStreamFlag, @@ -205,6 +211,7 @@ export function createConnectTransport( }); const { isUnaryError, unaryError } = validateResponse( method.kind, + useBinaryFormat, response.status, response.headers, ); @@ -268,6 +275,12 @@ export function createConnectTransport( break; } const { flags, data } = result.value; + if ((flags & compressedFlag) === compressedFlag) { + throw new ConnectError( + `protocol error: received unsupported compressed output`, + Code.Internal, + ); + } if ((flags & endStreamFlag) === endStreamFlag) { endStreamReceived = true; const endStream = endStreamFromJson(data); @@ -342,7 +355,12 @@ export function createConnectTransport( signal: req.signal, body: await createRequestBody(req.message), }); - validateResponse(method.kind, fRes.status, fRes.headers); + validateResponse( + method.kind, + useBinaryFormat, + fRes.status, + fRes.headers, + ); if (fRes.body === null) { throw "missing response body"; } diff --git a/packages/connect-web/src/grpc-web-transport.ts b/packages/connect-web/src/grpc-web-transport.ts index 15c6b4c70..350961603 100644 --- a/packages/connect-web/src/grpc-web-transport.ts +++ b/packages/connect-web/src/grpc-web-transport.ts @@ -31,8 +31,9 @@ import type { UnaryResponse, ContextValues, } from "@connectrpc/connect"; -import { createContextValues } from "@connectrpc/connect"; +import { createContextValues, ConnectError, Code } from "@connectrpc/connect"; import { + compressedFlag, createClientMethodSerializers, createEnvelopeReadableStream, createMethodUrl, @@ -41,6 +42,7 @@ import { runUnaryCall, } from "@connectrpc/connect/protocol"; import { + headerGrpcStatus, requestHeader, trailerFlag, trailerParse, @@ -180,8 +182,12 @@ export function createGrpcWebTransport( signal: req.signal, body: encodeEnvelope(0, serialize(req.message)), }); - validateResponse(response.status, response.headers); + const { headerError } = validateResponse( + response.status, + response.headers, + ); if (!response.body) { + if (headerError !== undefined) throw headerError; throw "missing response body"; } const reader = createEnvelopeReadableStream( @@ -195,6 +201,12 @@ export function createGrpcWebTransport( break; } const { flags, data } = r.value; + if ((flags & compressedFlag) === compressedFlag) { + throw new ConnectError( + `protocol error: received unsupported compressed output`, + Code.Internal, + ); + } if (flags === trailerFlag) { if (trailer !== undefined) { throw "extra trailer"; @@ -206,16 +218,25 @@ export function createGrpcWebTransport( continue; } if (message !== undefined) { - throw "extra message"; + throw new ConnectError("extra message", Code.Unimplemented); } message = parse(data); } if (trailer === undefined) { - throw "missing trailer"; + if (headerError !== undefined) throw headerError; + throw new ConnectError( + "missing trailer", + response.headers.has(headerGrpcStatus) + ? Code.Unimplemented + : Code.Unknown, + ); } validateTrailer(trailer, response.headers); if (message === undefined) { - throw "missing message"; + throw new ConnectError( + "missing message", + trailer.has(headerGrpcStatus) ? Code.Unimplemented : Code.Unknown, + ); } return { stream: false, @@ -253,6 +274,7 @@ export function createGrpcWebTransport( foundStatus: boolean, trailerTarget: Headers, header: Headers, + headerError: ConnectError | undefined, ) { const reader = createEnvelopeReadableStream(body).getReader(); if (foundStatus) { @@ -293,6 +315,9 @@ export function createGrpcWebTransport( continue; } if (!trailerReceived) { + if (headerError) { + throw headerError; + } throw "missing trailer"; } } @@ -342,8 +367,14 @@ export function createGrpcWebTransport( signal: req.signal, body: await createRequestBody(req.message), }); - const { foundStatus } = validateResponse(fRes.status, fRes.headers); + const { foundStatus, headerError } = validateResponse( + fRes.status, + fRes.headers, + ); if (!fRes.body) { + if (headerError != undefined) { + throw headerError; + } throw "missing response body"; } const trailer = new Headers(); @@ -356,6 +387,7 @@ export function createGrpcWebTransport( foundStatus, trailer, fRes.headers, + headerError, ), }; return res; diff --git a/packages/connect/src/promise-client.ts b/packages/connect/src/promise-client.ts index 4ba5d2243..c5a566b7a 100644 --- a/packages/connect/src/promise-client.ts +++ b/packages/connect/src/promise-client.ts @@ -163,13 +163,21 @@ export function createClientStreamingFn< ); options?.onHeader?.(response.header); let singleMessage: O | undefined; + let count = 0; for await (const message of response.message) { singleMessage = message; + count++; } if (!singleMessage) { throw new ConnectError( "protocol error: missing response message", - Code.Internal, + Code.Unimplemented, + ); + } + if (count > 1) { + throw new ConnectError( + "protocol error: received extra messages for client streaming method", + Code.Unimplemented, ); } options?.onTrailer?.(response.trailer); diff --git a/packages/connect/src/protocol-connect/end-stream.spec.ts b/packages/connect/src/protocol-connect/end-stream.spec.ts index 25d4e4c85..4204a03f7 100644 --- a/packages/connect/src/protocol-connect/end-stream.spec.ts +++ b/packages/connect/src/protocol-connect/end-stream.spec.ts @@ -40,17 +40,7 @@ describe("endStreamFromJson()", function () { metadata: false, }; expect(() => endStreamFromJson(JSON.stringify(json))).toThrowError( - "[invalid_argument] invalid end stream", - ); - }); - it("should raise protocol error on malformed error", function () { - const json: JsonObject = { - error: { - code: "OK", - }, - }; - expect(() => endStreamFromJson(JSON.stringify(json))).toThrowError( - "[invalid_argument] invalid end stream", + "[unknown] invalid end stream", ); }); }); diff --git a/packages/connect/src/protocol-connect/end-stream.ts b/packages/connect/src/protocol-connect/end-stream.ts index 2f4e7318b..d1c5719cd 100644 --- a/packages/connect/src/protocol-connect/end-stream.ts +++ b/packages/connect/src/protocol-connect/end-stream.ts @@ -50,7 +50,7 @@ export interface EndStreamResponse { export function endStreamFromJson( data: Uint8Array | string, ): EndStreamResponse { - const parseErr = new ConnectError("invalid end stream", Code.InvalidArgument); + const parseErr = new ConnectError("invalid end stream", Code.Unknown); let jsonValue: JsonValue; try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -89,7 +89,7 @@ export function endStreamFromJson( } } const error = - "error" in jsonValue + "error" in jsonValue && jsonValue.error != null ? errorFromJson(jsonValue.error, metadata, parseErr) : undefined; return { metadata, error }; diff --git a/packages/connect/src/protocol-connect/error-json.spec.ts b/packages/connect/src/protocol-connect/error-json.spec.ts index 8407fd31e..79d22ca0b 100644 --- a/packages/connect/src/protocol-connect/error-json.spec.ts +++ b/packages/connect/src/protocol-connect/error-json.spec.ts @@ -101,56 +101,60 @@ describe("errorFromJson()", () => { expect(error.rawMessage).toBe(""); }); it("with invalid code throws fallback", () => { - expect(() => - errorFromJson( - { - code: "wrong code", - message: "Not permitted", - }, - undefined, - new ConnectError("foo", Code.ResourceExhausted), - ), - ).toThrowError("[resource_exhausted] foo"); + const e = errorFromJson( + { + code: "wrong code", + message: "Not permitted", + }, + undefined, + new ConnectError("foo", Code.ResourceExhausted), + ); + expect(e).toBeInstanceOf(ConnectError); + expect(ConnectError.from(e).message).toBe( + "[resource_exhausted] Not permitted", + ); }); - it("with invalid code throws fallback with metadata", () => { - try { - errorFromJson( - { - code: "wrong code", - message: "Not permitted", - }, - new Headers({ foo: "bar" }), - new ConnectError("foo", Code.ResourceExhausted), - ); - fail("expected error"); - } catch (e) { - expect(e).toBeInstanceOf(ConnectError); - expect(ConnectError.from(e).message).toBe("[resource_exhausted] foo"); - expect(ConnectError.from(e).metadata.get("foo")).toBe("bar"); - } + it("with invalid code returns fallback code with metadata", () => { + const e = errorFromJson( + { + code: "wrong code", + message: "Not permitted", + }, + new Headers({ foo: "bar" }), + new ConnectError("foo", Code.ResourceExhausted), + ); + expect(e).toBeInstanceOf(ConnectError); + expect(ConnectError.from(e).message).toBe( + "[resource_exhausted] Not permitted", + ); + expect(ConnectError.from(e).metadata.get("foo")).toBe("bar"); }); - it("with code Ok throws fallback", () => { - expect(() => - errorFromJson( - { - code: "ok", - message: "Not permitted", - }, - undefined, - new ConnectError("foo", Code.ResourceExhausted), - ), - ).toThrowError("[resource_exhausted] foo"); + it("with code Ok returns fallback code", () => { + const e = errorFromJson( + { + code: "ok", + message: "Not permitted", + }, + undefined, + new ConnectError("foo", Code.ResourceExhausted), + ); + expect(e).toBeInstanceOf(ConnectError); + expect(ConnectError.from(e).message).toBe( + "[resource_exhausted] Not permitted", + ); }); - it("with missing code throws fallback", () => { - expect(() => - errorFromJson( - { - message: "Not permitted", - }, - undefined, - new ConnectError("foo", Code.ResourceExhausted), - ), - ).toThrowError("[resource_exhausted] foo"); + it("with missing code returns fallback code", () => { + const e = errorFromJson( + { + message: "Not permitted", + }, + undefined, + new ConnectError("foo", Code.ResourceExhausted), + ); + expect(e).toBeInstanceOf(ConnectError); + expect(ConnectError.from(e).message).toBe( + "[resource_exhausted] Not permitted", + ); }); describe("with details", () => { type ErrorDetail = Message & { diff --git a/packages/connect/src/protocol-connect/error-json.ts b/packages/connect/src/protocol-connect/error-json.ts index e874ca8a1..7e5930f33 100644 --- a/packages/connect/src/protocol-connect/error-json.ts +++ b/packages/connect/src/protocol-connect/error-json.ts @@ -41,15 +41,13 @@ export function errorFromJson( if ( typeof jsonValue !== "object" || jsonValue == null || - Array.isArray(jsonValue) || - !("code" in jsonValue) || - typeof jsonValue.code !== "string" + Array.isArray(jsonValue) ) { throw fallback; } - const code = codeFromString(jsonValue.code); - if (code === undefined) { - throw fallback; + let code = fallback.code; + if ("code" in jsonValue && typeof jsonValue.code === "string") { + code = codeFromString(jsonValue.code) ?? code; } const message = jsonValue.message; if (message != null && typeof message !== "string") { @@ -151,7 +149,7 @@ export function errorToJson( }) .map(({ value, ...rest }) => ({ ...rest, - value: protoBase64.enc(value), + value: protoBase64.enc(value).replace(/=+$/, ""), })); } return o; diff --git a/packages/connect/src/protocol-connect/transport.ts b/packages/connect/src/protocol-connect/transport.ts index c535a683a..efefb5312 100644 --- a/packages/connect/src/protocol-connect/transport.ts +++ b/packages/connect/src/protocol-connect/transport.ts @@ -144,6 +144,7 @@ export function createTransport(opt: CommonTransportOptions): Transport { validateResponseWithCompression( method.kind, opt.acceptCompression, + opt.useBinaryFormat, universalResponse.status, universalResponse.header, ); @@ -258,6 +259,7 @@ export function createTransport(opt: CommonTransportOptions): Transport { const { compression } = validateResponseWithCompression( method.kind, opt.acceptCompression, + opt.useBinaryFormat, uRes.status, uRes.header, ); diff --git a/packages/connect/src/protocol-connect/validate-response.spec.ts b/packages/connect/src/protocol-connect/validate-response.spec.ts index 12637a7e1..792a48b0c 100644 --- a/packages/connect/src/protocol-connect/validate-response.spec.ts +++ b/packages/connect/src/protocol-connect/validate-response.spec.ts @@ -22,6 +22,7 @@ describe("Connect validateResponse()", function () { it("should be successful for HTTP 200 with proper unary JSON content type", function () { const r = validateResponse( MethodKind.Unary, + false, 200, new Headers({ "Content-Type": "application/json" }), ); @@ -31,6 +32,7 @@ describe("Connect validateResponse()", function () { it("should return error for HTTP 204", function () { const r = validateResponse( MethodKind.Unary, + false, 204, new Headers({ "Content-Type": "application/json" }), ); @@ -40,6 +42,7 @@ describe("Connect validateResponse()", function () { it("should include headers as error metadata", function () { const r = validateResponse( MethodKind.Unary, + false, 204, new Headers({ "Content-Type": "application/json", Foo: "Bar" }), ); @@ -48,6 +51,7 @@ describe("Connect validateResponse()", function () { it("should be successful for HTTP 200 with proper unary proto content type", function () { const r = validateResponse( MethodKind.Unary, + true, 200, new Headers({ "Content-Type": "application/proto" }), ); @@ -58,6 +62,7 @@ describe("Connect validateResponse()", function () { try { validateResponse( MethodKind.Unary, + true, 400, new Headers({ "Content-Type": "application/proto", @@ -72,6 +77,7 @@ describe("Connect validateResponse()", function () { it("should return an error for HTTP error status if content type is JSON", function () { const result = validateResponse( MethodKind.Unary, + true, 400, new Headers({ "Content-Type": "application/json", @@ -87,6 +93,7 @@ describe("Connect validateResponse()", function () { try { validateResponse( MethodKind.BiDiStreaming, + true, 400, new Headers({ "Content-Type": "application/connect+proto", diff --git a/packages/connect/src/protocol-connect/validate-response.ts b/packages/connect/src/protocol-connect/validate-response.ts index 6fec47643..8e878d3f1 100644 --- a/packages/connect/src/protocol-connect/validate-response.ts +++ b/packages/connect/src/protocol-connect/validate-response.ts @@ -32,6 +32,7 @@ import type { Compression } from "../protocol/compression.js"; */ export function validateResponse( methodKind: MethodKind, + useBinaryFormat: boolean, status: number, headers: Headers, ): @@ -51,6 +52,20 @@ export function validateResponse( } throw errorFromStatus; } + const allowedContentType = { + binary: useBinaryFormat, + stream: methodKind !== MethodKind.Unary, + } satisfies ReturnType; + if ( + parsedType?.binary !== allowedContentType.binary || + parsedType.stream !== allowedContentType.stream + ) { + throw new ConnectError( + `unsupported content type ${mimeType}`, + parsedType === undefined ? Code.Unknown : Code.Internal, + headers, + ); + } return { isUnaryError: false }; } @@ -64,6 +79,7 @@ export function validateResponse( export function validateResponseWithCompression( methodKind: MethodKind, acceptCompression: Compression[], + useBinaryFormat: boolean, status: number, headers: Headers, ): ReturnType & { @@ -78,13 +94,13 @@ export function validateResponseWithCompression( if (!compression) { throw new ConnectError( `unsupported response encoding "${encoding}"`, - Code.InvalidArgument, + Code.Internal, headers, ); } } return { compression, - ...validateResponse(methodKind, status, headers), + ...validateResponse(methodKind, useBinaryFormat, status, headers), }; } diff --git a/packages/connect/src/protocol-grpc-web/transport.ts b/packages/connect/src/protocol-grpc-web/transport.ts index 21e26f280..ec4697f2d 100644 --- a/packages/connect/src/protocol-grpc-web/transport.ts +++ b/packages/connect/src/protocol-grpc-web/transport.ts @@ -49,6 +49,7 @@ import type { CommonTransportOptions } from "../protocol/transport-options.js"; import type { Transport } from "../transport.js"; import { createContextValues } from "../context-values.js"; import type { ContextValues } from "../context-values.js"; +import { headerGrpcStatus } from "./headers.js"; /** * Create a Transport for the gRPC-web protocol. @@ -121,7 +122,7 @@ export function createTransport(opt: CommonTransportOptions): Transport { }, ), }); - const { compression } = validateResponseWithCompression( + const { compression, headerError } = validateResponseWithCompression( opt.acceptCompression, uRes.status, uRes.header, @@ -143,7 +144,7 @@ export function createTransport(opt: CommonTransportOptions): Transport { if (trailer !== undefined) { throw new ConnectError( "protocol error: received extra trailer", - Code.InvalidArgument, + Code.Unimplemented, ); } trailer = env.value; @@ -151,7 +152,7 @@ export function createTransport(opt: CommonTransportOptions): Transport { if (message !== undefined) { throw new ConnectError( "protocol error: received extra output message for unary method", - Code.InvalidArgument, + Code.Unimplemented, ); } message = env.value; @@ -164,16 +165,21 @@ export function createTransport(opt: CommonTransportOptions): Transport { }, ); if (trailer === undefined) { + if (headerError != undefined) { + throw headerError; + } throw new ConnectError( "protocol error: missing trailer", - Code.InvalidArgument, + uRes.header.has(headerGrpcStatus) + ? Code.Unimplemented + : Code.Unknown, ); } validateTrailer(trailer, uRes.header); if (message === undefined) { throw new ConnectError( "protocol error: missing output message for unary method", - Code.InvalidArgument, + trailer.has(headerGrpcStatus) ? Code.Unimplemented : Code.Unknown, ); } return >{ @@ -255,11 +261,15 @@ export function createTransport(opt: CommonTransportOptions): Transport { { propagateDownStreamError: true }, ), }); - const { compression, foundStatus } = validateResponseWithCompression( - opt.acceptCompression, - uRes.status, - uRes.header, - ); + const { compression, foundStatus, headerError } = + validateResponseWithCompression( + opt.acceptCompression, + uRes.status, + uRes.header, + ); + if (headerError) { + throw headerError; + } const res: StreamResponse = { ...req, header: uRes.header, @@ -322,7 +332,7 @@ export function createTransport(opt: CommonTransportOptions): Transport { if (!trailerReceived) { throw new ConnectError( "protocol error: missing trailer", - Code.InvalidArgument, + Code.Internal, ); } }, diff --git a/packages/connect/src/protocol-grpc-web/validate-response.spec.ts b/packages/connect/src/protocol-grpc-web/validate-response.spec.ts index bd0d2aa98..473431596 100644 --- a/packages/connect/src/protocol-grpc-web/validate-response.spec.ts +++ b/packages/connect/src/protocol-grpc-web/validate-response.spec.ts @@ -21,7 +21,13 @@ describe("gRPC-web validateResponse()", function () { headers: Record, ): ConnectError | undefined { try { - validateResponse(httpStatus, new Headers(headers)); + const { headerError } = validateResponse( + httpStatus, + new Headers(headers), + ); + if (headerError) { + throw headerError; + } return undefined; } catch (e) { if (e instanceof ConnectError) { diff --git a/packages/connect/src/protocol-grpc-web/validate-response.ts b/packages/connect/src/protocol-grpc-web/validate-response.ts index 138e74c5a..01b2bcba4 100644 --- a/packages/connect/src/protocol-grpc-web/validate-response.ts +++ b/packages/connect/src/protocol-grpc-web/validate-response.ts @@ -38,15 +38,14 @@ import type { Compression } from "../protocol/compression.js"; export function validateResponse( status: number, headers: Headers, -): { foundStatus: boolean } { +): { foundStatus: boolean; headerError?: ConnectError } { // For compatibility with the `grpc-web` package, we treat all HTTP status // codes in the 200 range as valid, not just HTTP 200. if (status >= 200 && status < 300) { - const err = findTrailerError(headers); - if (err) { - throw err; - } - return { foundStatus: headers.has(headerGrpcStatus) }; + return { + foundStatus: headers.has(headerGrpcStatus), + headerError: findTrailerError(headers), + }; } throw new ConnectError( decodeURIComponent(headers.get(headerGrpcMessage) ?? `HTTP ${status}`), @@ -70,8 +69,12 @@ export function validateResponseWithCompression( acceptCompression: Compression[], status: number, headers: Headers, -): { foundStatus: boolean; compression: Compression | undefined } { - const { foundStatus } = validateResponse(status, headers); +): { + foundStatus: boolean; + compression: Compression | undefined; + headerError?: ConnectError; +} { + const { foundStatus, headerError } = validateResponse(status, headers); let compression: Compression | undefined; const encoding = headers.get(headerEncoding); if (encoding !== null && encoding.toLowerCase() !== "identity") { @@ -79,7 +82,7 @@ export function validateResponseWithCompression( if (!compression) { throw new ConnectError( `unsupported response encoding "${encoding}"`, - Code.InvalidArgument, + Code.Internal, headers, ); } @@ -87,5 +90,6 @@ export function validateResponseWithCompression( return { foundStatus, compression, + headerError, }; } diff --git a/packages/connect/src/protocol-grpc/transport.ts b/packages/connect/src/protocol-grpc/transport.ts index 79fba224a..689bdf5c8 100644 --- a/packages/connect/src/protocol-grpc/transport.ts +++ b/packages/connect/src/protocol-grpc/transport.ts @@ -48,6 +48,7 @@ import type { CommonTransportOptions } from "../protocol/transport-options.js"; import type { Transport } from "../transport.js"; import { createContextValues } from "../context-values.js"; import type { ContextValues } from "../context-values.js"; +import { headerGrpcStatus } from "./headers.js"; /** * Create a Transport for the gRPC protocol. @@ -119,7 +120,7 @@ export function createTransport(opt: CommonTransportOptions): Transport { }, ), }); - const { compression } = validateResponseWithCompression( + const { compression, headerError } = validateResponseWithCompression( opt.acceptCompression, uRes.status, uRes.header, @@ -135,7 +136,7 @@ export function createTransport(opt: CommonTransportOptions): Transport { if (message !== undefined) { throw new ConnectError( "protocol error: received extra output message for unary method", - Code.InvalidArgument, + Code.Unimplemented, ); } message = chunk; @@ -148,7 +149,15 @@ export function createTransport(opt: CommonTransportOptions): Transport { if (message === undefined) { throw new ConnectError( "protocol error: missing output message for unary method", - Code.InvalidArgument, + uRes.trailer.has(headerGrpcStatus) + ? Code.Unimplemented + : Code.Unknown, + ); + } + if (headerError) { + throw new ConnectError( + "protocol error: received output message for unary method with error status", + Code.Unknown, ); } return >{ @@ -225,11 +234,15 @@ export function createTransport(opt: CommonTransportOptions): Transport { { propagateDownStreamError: true }, ), }); - const { compression, foundStatus } = validateResponseWithCompression( - opt.acceptCompression, - uRes.status, - uRes.header, - ); + const { compression, foundStatus, headerError } = + validateResponseWithCompression( + opt.acceptCompression, + uRes.status, + uRes.header, + ); + if (headerError) { + throw headerError; + } const res: StreamResponse = { ...req, header: uRes.header, diff --git a/packages/connect/src/protocol-grpc/validate-response.spec.ts b/packages/connect/src/protocol-grpc/validate-response.spec.ts index 95d6a75e8..d46ca0497 100644 --- a/packages/connect/src/protocol-grpc/validate-response.spec.ts +++ b/packages/connect/src/protocol-grpc/validate-response.spec.ts @@ -21,7 +21,13 @@ describe("gRPC validateResponse()", function () { headers: Record, ): ConnectError | undefined { try { - validateResponse(httpStatus, new Headers(headers)); + const { headerError } = validateResponse( + httpStatus, + new Headers(headers), + ); + if (headerError) { + throw headerError; + } return undefined; } catch (e) { if (e instanceof ConnectError) { diff --git a/packages/connect/src/protocol-grpc/validate-response.ts b/packages/connect/src/protocol-grpc/validate-response.ts index 14b1bcfe1..2245f43c1 100644 --- a/packages/connect/src/protocol-grpc/validate-response.ts +++ b/packages/connect/src/protocol-grpc/validate-response.ts @@ -33,7 +33,7 @@ import type { Compression } from "../protocol/compression.js"; export function validateResponse( status: number, headers: Headers, -): { foundStatus: boolean } { +): { foundStatus: boolean; headerError: ConnectError | undefined } { if (status != 200) { throw new ConnectError( `HTTP ${status}`, @@ -41,11 +41,10 @@ export function validateResponse( headers, ); } - const err = findTrailerError(headers); - if (err) { - throw err; - } - return { foundStatus: headers.has(headerGrpcStatus) }; + return { + foundStatus: headers.has(headerGrpcStatus), + headerError: findTrailerError(headers), + }; } /** @@ -63,8 +62,12 @@ export function validateResponseWithCompression( acceptCompression: Compression[], status: number, headers: Headers, -): { foundStatus: boolean; compression: Compression | undefined } { - const { foundStatus } = validateResponse(status, headers); +): { + foundStatus: boolean; + compression: Compression | undefined; + headerError: ConnectError | undefined; +} { + const { foundStatus, headerError } = validateResponse(status, headers); let compression: Compression | undefined; const encoding = headers.get(headerEncoding); if (encoding !== null && encoding.toLowerCase() !== "identity") { @@ -72,7 +75,7 @@ export function validateResponseWithCompression( if (!compression) { throw new ConnectError( `unsupported response encoding "${encoding}"`, - Code.InvalidArgument, + Code.Internal, headers, ); } @@ -80,5 +83,6 @@ export function validateResponseWithCompression( return { foundStatus, compression, + headerError, }; } diff --git a/packages/connect/src/protocol/async-iterable.spec.ts b/packages/connect/src/protocol/async-iterable.spec.ts index 17b83fb8c..acbac6d28 100644 --- a/packages/connect/src/protocol/async-iterable.spec.ts +++ b/packages/connect/src/protocol/async-iterable.spec.ts @@ -790,7 +790,7 @@ describe("transforming asynchronous iterables", () => { } catch (e) { expect(e).toBeInstanceOf(ConnectError); expect(ConnectError.from(e).message).toBe( - "[invalid_argument] received compressed envelope, but do not know how to decompress", + "[internal] received compressed envelope, but do not know how to decompress", ); } }); diff --git a/packages/connect/src/protocol/envelope.spec.ts b/packages/connect/src/protocol/envelope.spec.ts index 4eeb064ab..6cb3d3dcf 100644 --- a/packages/connect/src/protocol/envelope.spec.ts +++ b/packages/connect/src/protocol/envelope.spec.ts @@ -233,7 +233,7 @@ describe("envelope compression", function () { } catch (e) { expect(e).toBeInstanceOf(ConnectError); expect(ConnectError.from(e).message).toBe( - "[invalid_argument] received compressed envelope, but do not know how to decompress", + "[internal] received compressed envelope, but do not know how to decompress", ); } }); diff --git a/packages/connect/src/protocol/envelope.ts b/packages/connect/src/protocol/envelope.ts index 7b8a9ff1e..ff0648a61 100644 --- a/packages/connect/src/protocol/envelope.ts +++ b/packages/connect/src/protocol/envelope.ts @@ -147,7 +147,7 @@ export async function envelopeDecompress( if (!compression) { throw new ConnectError( "received compressed envelope, but do not know how to decompress", - Code.InvalidArgument, + Code.Internal, ); } data = await compression.decompress(data, readMaxBytes); diff --git a/packages/connect/src/protocol/invoke-implementation.ts b/packages/connect/src/protocol/invoke-implementation.ts index 16435e6e4..2cdc1bfba 100644 --- a/packages/connect/src/protocol/invoke-implementation.ts +++ b/packages/connect/src/protocol/invoke-implementation.ts @@ -104,7 +104,7 @@ export function transformInvokeImplementation< if (input1.done === true) { throw new ConnectError( "protocol error: missing input message for unary method", - Code.InvalidArgument, + Code.Unimplemented, ); } const anyFn = async ( @@ -150,7 +150,7 @@ export function transformInvokeImplementation< if (input2.done !== true) { throw new ConnectError( "protocol error: received extra input message for unary method", - Code.InvalidArgument, + Code.Unimplemented, ); } }; @@ -161,7 +161,7 @@ export function transformInvokeImplementation< if (input1.done === true) { throw new ConnectError( "protocol error: missing input message for server-streaming method", - Code.InvalidArgument, + Code.Unimplemented, ); } const anyFn = async ( @@ -208,7 +208,7 @@ export function transformInvokeImplementation< if (input2.done !== true) { throw new ConnectError( "protocol error: received extra input message for server-streaming method", - Code.InvalidArgument, + Code.Unimplemented, ); } }; diff --git a/packages/connect/src/protocol/serialization.spec.ts b/packages/connect/src/protocol/serialization.spec.ts index c9c9c1a36..ac1aa17ee 100644 --- a/packages/connect/src/protocol/serialization.spec.ts +++ b/packages/connect/src/protocol/serialization.spec.ts @@ -45,9 +45,7 @@ describe("createBinarySerialization()", function () { } catch (e) { expect(e).toBeInstanceOf(ConnectError); const c = ConnectError.from(e); - expect(c.message).toBe( - "[invalid_argument] parse binary: premature EOF", - ); + expect(c.message).toBe("[internal] parse binary: premature EOF"); } }); }); diff --git a/packages/connect/src/protocol/serialization.ts b/packages/connect/src/protocol/serialization.ts index 9652e59c6..0cab9f6c3 100644 --- a/packages/connect/src/protocol/serialization.ts +++ b/packages/connect/src/protocol/serialization.ts @@ -192,7 +192,7 @@ export function createBinarySerialization>( return messageType.fromBinary(data, options); } catch (e) { const m = e instanceof Error ? e.message : String(e); - throw new ConnectError(`parse binary: ${m}`, Code.InvalidArgument); + throw new ConnectError(`parse binary: ${m}`, Code.Internal); } }, serialize(data: T): Uint8Array {