diff --git a/grpc-protoscope/README.md b/grpc-protoscope/README.md new file mode 100644 index 00000000..1f61ea45 --- /dev/null +++ b/grpc-protoscope/README.md @@ -0,0 +1,346 @@ +# grpc-protoscope — Reproducing the Keploy gRPC Field-Ordering Bug + +## Table of Contents + +1. [The Issue Reported by the Client](#1-the-issue-reported-by-the-client) +2. [Root Cause Analysis](#2-root-cause-analysis) +3. [About This Sample Application](#3-about-this-sample-application) +4. [How to Run](#4-how-to-run) +5. [Reproducing the Bug with Keploy](#5-reproducing-the-bug-with-keploy) +6. [Files in This Repository](#6-files-in-this-repository) + +--- + +## 1. The Issue Reported by the Client + +A user reported that Keploy was **failing gRPC tests** even though the recorded and replayed responses had **identical structure and values**. The only difference was the **order of individual fields** inside nested protobuf sub-messages. + +### Client's Exact Input + +```yaml +expected: |- + 1: 67.0i32 # 0x42860000i32 + 4: {"{\"hits\":[{\"_index\":\"pvid_search_products_v4\",\"_score\":15100000000000000000,\"_sou" + "rce\":{\"_rankingInfo\":{\"typosPresent\":true,\"numberOfWordsMatched\":1}},\"match_type" + "\":\"Other\",\"attributes\":{\"subThemes\":null},\"_id\":\"4f30407c-6a3c-4a4e-8a3d-652217d" + "4b6cb_d67c25f8-3adb-40c1-9113-b46d54a6e8aa\",\"trimming_meta\":{\"trimming_type\":\"L3" + "\"}}]}"} + 8: 0 + 9: { 3: { 1: { 2: {2: 0.0} # 0x0i64 + 1: {"candidateCnt"}} + 1: { 2: {3: {"OVS"}} + 1: {"type"}}} + 2: { 1: { 2: {2: 1.0} # 0x3ff0000000000000i64 + 1: {"candidateCnt"}} + 1: { 2: {2: 1.0} # 0x3ff0000000000000i64 + 1: {"resultCnt"}}}} +actual: |- + 1: 67.0i32 # 0x42860000i32 + 4: {"{\"hits\":[{\"_index\":\"pvid_search_products_v4\",\"_score\":15100000000000000000,\"_sou" + "rce\":{\"_rankingInfo\":{\"typosPresent\":true,\"numberOfWordsMatched\":1}},\"match_type" + "\":\"Other\",\"attributes\":{\"subThemes\":null},\"_id\":\"4f30407c-6a3c-4a4e-8a3d-652217d" + "4b6cb_d67c25f8-3adb-40c1-9113-b46d54a6e8aa\",\"trimming_meta\":{\"trimming_type\":\"L3" + "\"}}]}"} + 8: 0 + 9: { 3: { 1: { 2: {3: {"OVS"}} + 1: {"type"}} + 1: { 2: {2: 0.0} # 0x0i64 + 1: {"candidateCnt"}}} + 2: { 1: { 2: {2: 1.0} # 0x3ff0000000000000i64 + 1: {"candidateCnt"}} + 1: { 2: {2: 1.0} # 0x3ff0000000000000i64 + 1: {"resultCnt"}}}} +``` + +### The Failure Classification + +```yaml +failure_info: + risk: HIGH + category: + - SCHEMA_BROKEN +``` + +### What's Actually Different? + +If you look closely at field `9.3` (the availability facet bucket), the **same two sub-messages** appear but in **reversed order**: + +**Expected** (recorded): +``` +9: { 3: { 1: { 2: {2: 0.0} # candidateCnt (numeric=0.0) + 1: {"candidateCnt"}} + 1: { 2: {3: {"OVS"}} # type (text="OVS") + 1: {"type"}}} +``` + +**Actual** (replayed): +``` +9: { 3: { 1: { 2: {3: {"OVS"}} # type (text="OVS") — now first + 1: {"type"}} + 1: { 2: {2: 0.0} # 0x0i64 # candidateCnt — now second + 1: {"candidateCnt"}}} +``` + +The values are **identical**: `candidateCnt = 0.0` and `type = "OVS"`. Only the wire serialization order changed — which is **perfectly valid** in protobuf, where `repeated` fields and map entries have no guaranteed order. + +--- + +## 2. Root Cause Analysis + +The bug lives in **three interacting layers** in Keploy's codebase. + +### Layer 1: Protoscope Assigns Position-Dependent Indentation + +Keploy uses the [`protoscope`](https://github.com/protocolbuffers/protoscope) library to convert raw protobuf wire bytes into human-readable text. The protoscope renderer assigns **indentation based on position**, not content. + +When a sub-message is small enough, protoscope inlines it on the same line as the parent `{`: + +``` +9: { 3: { 1: { 2: {2: 0.0} # 0x0i64 ← 6 spaces indent (inline) + 1: {"candidateCnt"}} ← 2 spaces indent (next line) +``` + +The **first** sub-message gets deeper inline indentation (it continues on the same line as `{`). The **second** sub-message starts on a new line with less indentation. So when the wire order flips, the same content gets **different leading whitespace**. + +### Layer 2: Canonicalization Sorts With Indentation Included + +The canonicalization function in `pkg/matcher/grpc/canonical.go` (`CanonicalizeTopLevelBlocks`) is designed to make protoscope text order-insensitive. It: + +1. Splits text into "top-level field blocks" (lines starting with `\d+:`) +2. Recursively canonicalizes the content inside each `{...}` block +3. **Sorts blocks lexicographically** +4. Joins them back + +The problem: `normalizeWhitespace()` only trims **trailing** whitespace and collapses blank lines. It does **not** strip or normalize **leading** indentation. So when `sort.Strings(blocks)` runs, the sort order is determined by the leading spaces, not the content: + +``` +" 2: {2: 0.0}" sorts before " 1: {\"candidateCnt\"}" +``` + +because `" "` (6 spaces) sorts before `" 1"` (2 spaces then `1`) in ASCII. But when the wire order flips, the indentation flips too, producing a different sorted result — even though the actual protobuf data is identical. + +### Layer 3: Non-JSON Mismatch Is Classified as SCHEMA_BROKEN + +In `pkg/matcher/grpc/match.go`, when the two canonicalized strings don't match: + +```go +if !decodedDataNormal { + if json.Valid([]byte(expectedDecodedData)) && json.Valid([]byte(actualDecodedData)) { + // JSON comparison with failure assessment + } else { + // non-JSON payload mismatch → Broken + currentRisk = models.High + currentCategories = append(currentCategories, models.SchemaBroken) + } +} +``` + +Since protoscope text is **not valid JSON**, it falls into the `else` branch, which unconditionally classifies the failure as `HIGH` risk / `SCHEMA_BROKEN` — the most alarming category. + +### Summary of the Chain + +``` +Wire bytes have different field order (valid in protobuf) + → protoscope assigns different indentation + → canonicalization sorts by indentation instead of content + → canonicalized strings differ + → classified as SCHEMA_BROKEN / HIGH risk +``` + +### The Fix + +The fix needs to **strip leading whitespace from each block before sorting** in `canonicalizeRecursive`: + +```go +// Before sorting, strip leading whitespace so that +// sort order depends on content, not position-dependent indentation. +for i := range blocks { + blocks[i] = strings.TrimLeft(blocks[i], " \t") +} +sort.Strings(blocks) +``` + +--- + +## 3. About This Sample Application + +This is a minimal Go gRPC client-server app that reproduces the exact conditions from the bug report. + +### Why a Normal gRPC Server Isn't Enough + +Go's standard `proto.Marshal()` serializes `repeated` fields and `map` entries in a **deterministic** (sorted) order. So a normal gRPC server would produce identical wire bytes on every call — the bug would never trigger. + +### What This Server Does Differently + +The server uses **raw wire encoding** via `google.golang.org/protobuf/encoding/protowire` to manually construct the protobuf response bytes with `rand.Shuffle()` on the repeated field entries: + +```go +availEntries := [][]byte{ + buildFacetEntry("candidateCnt", &zero, nil), + buildFacetEntry("type", nil, &ovs), +} +rand.Shuffle(len(availEntries), func(i, j int) { + availEntries[i], availEntries[j] = availEntries[j], availEntries[i] +}) +``` + +A `rawCodec` gRPC codec passes these pre-built bytes straight to the wire without re-marshaling, preserving the randomized field ordering. + +### Proto Schema + +```protobuf +message FacetValue { + oneof value { + double numeric = 2; + string text = 3; + } +} + +message FacetEntry { + string name = 1; + FacetValue data = 2; +} + +message FacetBucket { + repeated FacetEntry entries = 1; +} + +message FacetInfo { + FacetBucket pricing = 2; + FacetBucket availability = 3; +} + +message SearchResponse { + float score = 1; + string hits_json = 4; + int32 total = 8; + FacetInfo facets = 9; +} +``` + +The field numbers (`1`, `4`, `8`, `9`) and nesting structure match the bug report exactly. + +### Example: Recorded Test Case (Protoscope Format) + +When Keploy records this server's response, the YAML test case looks like this: + +```yaml +decoded_data: | + 1: 67.0i32 # 0x42860000i32 + 4: { + "{\"hits\":[{\"_index\":\"pvid_search_products_v4\",..." + } + 8: 0 + 9: { + 3: { + 1: { + 1: {"type"} + 2: {3: {"OVS"}} + } + 1: { + 1: {"candidateCnt"} + 2: {2: 0.0} # 0x0i64 + } + } + 2: { + 1: { + 1: {"candidateCnt"} + 2: {2: 1.0} # 0x3ff0000000000000i64 + } + 1: { + 1: {"resultCnt"} + 2: {2: 1.0} # 0x3ff0000000000000i64 + } + } + } +``` + +On the next run (test mode), the `rand.Shuffle` may flip the inner field order, producing different protoscope indentation — triggering the `SCHEMA_BROKEN` false positive. + +--- + +## 4. How to Run + +### Prerequisites + +- Go 1.24+ +- `protoc` compiler (only needed if modifying the `.proto` file) + +### Run without Keploy + +```bash +# Terminal 1 — start the server +go run ./server/ + +# Terminal 2 — call it (run multiple times to see different field orderings) +go run ./client/ +go run ./client/ +go run ./client/ +``` + +You'll see the facet entries printed in different orders across calls. + +--- + +## 5. Reproducing the Bug with Keploy + +### Step 1: Install Keploy + +Install Keploy using the [official installation guide](https://keploy.io/docs/server/installation/), or build from source: + +```bash +git clone https://github.com/keploy/keploy.git && cd keploy +go build -ldflags="-X main.apiServerURI=https://api.keploy.io" -o keploy +export PATH=$PWD:$PATH +``` + +### Step 2: Record a test case + +```bash +# Start recording +keploy record -c "go run ./server/" +``` + +In another terminal, trigger the gRPC call: + +```bash +go run ./client/ +``` + +Then press `Ctrl+C` in the recording terminal. Keploy saves the test case in `keploy/test-set-0/tests/test-1.yaml`. + +### Step 3: Replay (test mode) + +```bash +keploy test -c "go run ./server/" +``` + +**Expected result:** Because `rand.Shuffle` randomizes field ordering each time, ~50% of test runs will produce a different wire order than the recording, triggering: + +``` +failure_info: + risk: HIGH + category: + - SCHEMA_BROKEN +``` + +If the test passes (same random order happened to match), delete the `keploy/` folder and repeat steps 2–3. + +--- + +## 6. Files in This Repository + +``` +grpc-protoscope/ +├── README.md ← This file +├── go.mod +├── go.sum +├── proto/search.proto ← Protobuf schema matching the bug report structure +├── searchpb/ ← Generated Go protobuf/gRPC code +│ ├── search.pb.go +│ └── search_grpc.pb.go +├── server/main.go ← gRPC server with randomized wire field ordering +└── client/main.go ← gRPC client that calls the Search RPC +``` + +> **Note:** The `keploy/` directory (test artifacts) is generated at runtime when you run `keploy record` and is not checked into the repository. diff --git a/grpc-protoscope/client/main.go b/grpc-protoscope/client/main.go new file mode 100644 index 00000000..c0ba8fd3 --- /dev/null +++ b/grpc-protoscope/client/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + pb "zepto-grpc/searchpb" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func main() { + conn, err := grpc.NewClient("localhost:50051", + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + log.Fatalf("failed to connect to localhost:50051: %v (ensure the server is running)", err) + } + defer conn.Close() + + client := pb.NewSearchServiceClient(conn) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + resp, err := client.Search(ctx, &pb.SearchRequest{Query: "shoes"}) + if err != nil { + log.Fatalf("Search RPC failed: %v (ensure the server is running and the service is registered)", err) + } + + fmt.Printf("Score: %.1f\n", resp.GetScore()) + fmt.Printf("Total: %d\n", resp.GetTotal()) + fmt.Printf("HitsJSON: %.80s...\n", resp.GetHitsJson()) + fmt.Println("Facets:") + if f := resp.GetFacets(); f != nil { + if a := f.GetAvailability(); a != nil { + fmt.Println(" availability:") + for _, e := range a.GetEntries() { + fmt.Printf(" %s → ", e.GetName()) + if d := e.GetData(); d != nil { + switch v := d.GetValue().(type) { + case *pb.FacetValue_Numeric: + fmt.Printf("%.1f\n", v.Numeric) + case *pb.FacetValue_Text: + fmt.Printf("%s\n", v.Text) + } + } + } + } + if p := f.GetPricing(); p != nil { + fmt.Println(" pricing:") + for _, e := range p.GetEntries() { + fmt.Printf(" %s → ", e.GetName()) + if d := e.GetData(); d != nil { + switch v := d.GetValue().(type) { + case *pb.FacetValue_Numeric: + fmt.Printf("%.1f\n", v.Numeric) + case *pb.FacetValue_Text: + fmt.Printf("%s\n", v.Text) + } + } + } + } + } +} diff --git a/grpc-protoscope/go.mod b/grpc-protoscope/go.mod new file mode 100644 index 00000000..fd8dbd43 --- /dev/null +++ b/grpc-protoscope/go.mod @@ -0,0 +1,15 @@ +module zepto-grpc + +go 1.24.0 + +require ( + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect +) diff --git a/grpc-protoscope/go.sum b/grpc-protoscope/go.sum new file mode 100644 index 00000000..37f73fbd --- /dev/null +++ b/grpc-protoscope/go.sum @@ -0,0 +1,38 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/grpc-protoscope/proto/search.proto b/grpc-protoscope/proto/search.proto new file mode 100644 index 00000000..1efd4ebc --- /dev/null +++ b/grpc-protoscope/proto/search.proto @@ -0,0 +1,43 @@ +syntax = "proto3"; + +package search; + +option go_package = "./searchpb"; + +service SearchService { + rpc Search(SearchRequest) returns (SearchResponse); +} + +message SearchRequest { + string query = 1; +} + +// Minimal nested structure — small enough that protoscope +// will inline the sub-messages, producing position-dependent indentation. +message FacetValue { + oneof value { + double numeric = 2; + string text = 3; + } +} + +message FacetEntry { + string name = 1; + FacetValue data = 2; +} + +message FacetBucket { + repeated FacetEntry entries = 1; +} + +message FacetInfo { + FacetBucket pricing = 2; + FacetBucket availability = 3; +} + +message SearchResponse { + float score = 1; + string hits_json = 4; + int32 total = 8; + FacetInfo facets = 9; +} diff --git a/grpc-protoscope/searchpb/search.pb.go b/grpc-protoscope/searchpb/search.pb.go new file mode 100644 index 00000000..b49a99d3 --- /dev/null +++ b/grpc-protoscope/searchpb/search.pb.go @@ -0,0 +1,460 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v3.21.12 +// source: proto/search.proto + +package searchpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type SearchRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Query string `protobuf:"bytes,1,opt,name=query,proto3" json:"query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SearchRequest) Reset() { + *x = SearchRequest{} + mi := &file_proto_search_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SearchRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SearchRequest) ProtoMessage() {} + +func (x *SearchRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_search_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SearchRequest.ProtoReflect.Descriptor instead. +func (*SearchRequest) Descriptor() ([]byte, []int) { + return file_proto_search_proto_rawDescGZIP(), []int{0} +} + +func (x *SearchRequest) GetQuery() string { + if x != nil { + return x.Query + } + return "" +} + +// Minimal nested structure — small enough that protoscope +// will inline the sub-messages, producing position-dependent indentation. +type FacetValue struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Value: + // + // *FacetValue_Numeric + // *FacetValue_Text + Value isFacetValue_Value `protobuf_oneof:"value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FacetValue) Reset() { + *x = FacetValue{} + mi := &file_proto_search_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FacetValue) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FacetValue) ProtoMessage() {} + +func (x *FacetValue) ProtoReflect() protoreflect.Message { + mi := &file_proto_search_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FacetValue.ProtoReflect.Descriptor instead. +func (*FacetValue) Descriptor() ([]byte, []int) { + return file_proto_search_proto_rawDescGZIP(), []int{1} +} + +func (x *FacetValue) GetValue() isFacetValue_Value { + if x != nil { + return x.Value + } + return nil +} + +func (x *FacetValue) GetNumeric() float64 { + if x != nil { + if x, ok := x.Value.(*FacetValue_Numeric); ok { + return x.Numeric + } + } + return 0 +} + +func (x *FacetValue) GetText() string { + if x != nil { + if x, ok := x.Value.(*FacetValue_Text); ok { + return x.Text + } + } + return "" +} + +type isFacetValue_Value interface { + isFacetValue_Value() +} + +type FacetValue_Numeric struct { + Numeric float64 `protobuf:"fixed64,2,opt,name=numeric,proto3,oneof"` +} + +type FacetValue_Text struct { + Text string `protobuf:"bytes,3,opt,name=text,proto3,oneof"` +} + +func (*FacetValue_Numeric) isFacetValue_Value() {} + +func (*FacetValue_Text) isFacetValue_Value() {} + +type FacetEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Data *FacetValue `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FacetEntry) Reset() { + *x = FacetEntry{} + mi := &file_proto_search_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FacetEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FacetEntry) ProtoMessage() {} + +func (x *FacetEntry) ProtoReflect() protoreflect.Message { + mi := &file_proto_search_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FacetEntry.ProtoReflect.Descriptor instead. +func (*FacetEntry) Descriptor() ([]byte, []int) { + return file_proto_search_proto_rawDescGZIP(), []int{2} +} + +func (x *FacetEntry) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *FacetEntry) GetData() *FacetValue { + if x != nil { + return x.Data + } + return nil +} + +type FacetBucket struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entries []*FacetEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FacetBucket) Reset() { + *x = FacetBucket{} + mi := &file_proto_search_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FacetBucket) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FacetBucket) ProtoMessage() {} + +func (x *FacetBucket) ProtoReflect() protoreflect.Message { + mi := &file_proto_search_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FacetBucket.ProtoReflect.Descriptor instead. +func (*FacetBucket) Descriptor() ([]byte, []int) { + return file_proto_search_proto_rawDescGZIP(), []int{3} +} + +func (x *FacetBucket) GetEntries() []*FacetEntry { + if x != nil { + return x.Entries + } + return nil +} + +type FacetInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Pricing *FacetBucket `protobuf:"bytes,2,opt,name=pricing,proto3" json:"pricing,omitempty"` + Availability *FacetBucket `protobuf:"bytes,3,opt,name=availability,proto3" json:"availability,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FacetInfo) Reset() { + *x = FacetInfo{} + mi := &file_proto_search_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FacetInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FacetInfo) ProtoMessage() {} + +func (x *FacetInfo) ProtoReflect() protoreflect.Message { + mi := &file_proto_search_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FacetInfo.ProtoReflect.Descriptor instead. +func (*FacetInfo) Descriptor() ([]byte, []int) { + return file_proto_search_proto_rawDescGZIP(), []int{4} +} + +func (x *FacetInfo) GetPricing() *FacetBucket { + if x != nil { + return x.Pricing + } + return nil +} + +func (x *FacetInfo) GetAvailability() *FacetBucket { + if x != nil { + return x.Availability + } + return nil +} + +type SearchResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Score float32 `protobuf:"fixed32,1,opt,name=score,proto3" json:"score,omitempty"` + HitsJson string `protobuf:"bytes,4,opt,name=hits_json,json=hitsJson,proto3" json:"hits_json,omitempty"` + Total int32 `protobuf:"varint,8,opt,name=total,proto3" json:"total,omitempty"` + Facets *FacetInfo `protobuf:"bytes,9,opt,name=facets,proto3" json:"facets,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SearchResponse) Reset() { + *x = SearchResponse{} + mi := &file_proto_search_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SearchResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SearchResponse) ProtoMessage() {} + +func (x *SearchResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_search_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SearchResponse.ProtoReflect.Descriptor instead. +func (*SearchResponse) Descriptor() ([]byte, []int) { + return file_proto_search_proto_rawDescGZIP(), []int{5} +} + +func (x *SearchResponse) GetScore() float32 { + if x != nil { + return x.Score + } + return 0 +} + +func (x *SearchResponse) GetHitsJson() string { + if x != nil { + return x.HitsJson + } + return "" +} + +func (x *SearchResponse) GetTotal() int32 { + if x != nil { + return x.Total + } + return 0 +} + +func (x *SearchResponse) GetFacets() *FacetInfo { + if x != nil { + return x.Facets + } + return nil +} + +var File_proto_search_proto protoreflect.FileDescriptor + +const file_proto_search_proto_rawDesc = "" + + "\n" + + "\x12proto/search.proto\x12\x06search\"%\n" + + "\rSearchRequest\x12\x14\n" + + "\x05query\x18\x01 \x01(\tR\x05query\"G\n" + + "\n" + + "FacetValue\x12\x1a\n" + + "\anumeric\x18\x02 \x01(\x01H\x00R\anumeric\x12\x14\n" + + "\x04text\x18\x03 \x01(\tH\x00R\x04textB\a\n" + + "\x05value\"H\n" + + "\n" + + "FacetEntry\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12&\n" + + "\x04data\x18\x02 \x01(\v2\x12.search.FacetValueR\x04data\";\n" + + "\vFacetBucket\x12,\n" + + "\aentries\x18\x01 \x03(\v2\x12.search.FacetEntryR\aentries\"s\n" + + "\tFacetInfo\x12-\n" + + "\apricing\x18\x02 \x01(\v2\x13.search.FacetBucketR\apricing\x127\n" + + "\favailability\x18\x03 \x01(\v2\x13.search.FacetBucketR\favailability\"\x84\x01\n" + + "\x0eSearchResponse\x12\x14\n" + + "\x05score\x18\x01 \x01(\x02R\x05score\x12\x1b\n" + + "\thits_json\x18\x04 \x01(\tR\bhitsJson\x12\x14\n" + + "\x05total\x18\b \x01(\x05R\x05total\x12)\n" + + "\x06facets\x18\t \x01(\v2\x11.search.FacetInfoR\x06facets2H\n" + + "\rSearchService\x127\n" + + "\x06Search\x12\x15.search.SearchRequest\x1a\x16.search.SearchResponseB\fZ\n" + + "./searchpbb\x06proto3" + +var ( + file_proto_search_proto_rawDescOnce sync.Once + file_proto_search_proto_rawDescData []byte +) + +func file_proto_search_proto_rawDescGZIP() []byte { + file_proto_search_proto_rawDescOnce.Do(func() { + file_proto_search_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_search_proto_rawDesc), len(file_proto_search_proto_rawDesc))) + }) + return file_proto_search_proto_rawDescData +} + +var file_proto_search_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_proto_search_proto_goTypes = []any{ + (*SearchRequest)(nil), // 0: search.SearchRequest + (*FacetValue)(nil), // 1: search.FacetValue + (*FacetEntry)(nil), // 2: search.FacetEntry + (*FacetBucket)(nil), // 3: search.FacetBucket + (*FacetInfo)(nil), // 4: search.FacetInfo + (*SearchResponse)(nil), // 5: search.SearchResponse +} +var file_proto_search_proto_depIdxs = []int32{ + 1, // 0: search.FacetEntry.data:type_name -> search.FacetValue + 2, // 1: search.FacetBucket.entries:type_name -> search.FacetEntry + 3, // 2: search.FacetInfo.pricing:type_name -> search.FacetBucket + 3, // 3: search.FacetInfo.availability:type_name -> search.FacetBucket + 4, // 4: search.SearchResponse.facets:type_name -> search.FacetInfo + 0, // 5: search.SearchService.Search:input_type -> search.SearchRequest + 5, // 6: search.SearchService.Search:output_type -> search.SearchResponse + 6, // [6:7] is the sub-list for method output_type + 5, // [5:6] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_proto_search_proto_init() } +func file_proto_search_proto_init() { + if File_proto_search_proto != nil { + return + } + file_proto_search_proto_msgTypes[1].OneofWrappers = []any{ + (*FacetValue_Numeric)(nil), + (*FacetValue_Text)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_search_proto_rawDesc), len(file_proto_search_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_search_proto_goTypes, + DependencyIndexes: file_proto_search_proto_depIdxs, + MessageInfos: file_proto_search_proto_msgTypes, + }.Build() + File_proto_search_proto = out.File + file_proto_search_proto_goTypes = nil + file_proto_search_proto_depIdxs = nil +} diff --git a/grpc-protoscope/searchpb/search_grpc.pb.go b/grpc-protoscope/searchpb/search_grpc.pb.go new file mode 100644 index 00000000..5bc0832b --- /dev/null +++ b/grpc-protoscope/searchpb/search_grpc.pb.go @@ -0,0 +1,121 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v3.21.12 +// source: proto/search.proto + +package searchpb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + SearchService_Search_FullMethodName = "/search.SearchService/Search" +) + +// SearchServiceClient is the client API for SearchService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type SearchServiceClient interface { + Search(ctx context.Context, in *SearchRequest, opts ...grpc.CallOption) (*SearchResponse, error) +} + +type searchServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewSearchServiceClient(cc grpc.ClientConnInterface) SearchServiceClient { + return &searchServiceClient{cc} +} + +func (c *searchServiceClient) Search(ctx context.Context, in *SearchRequest, opts ...grpc.CallOption) (*SearchResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SearchResponse) + err := c.cc.Invoke(ctx, SearchService_Search_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// SearchServiceServer is the server API for SearchService service. +// All implementations must embed UnimplementedSearchServiceServer +// for forward compatibility. +type SearchServiceServer interface { + Search(context.Context, *SearchRequest) (*SearchResponse, error) + mustEmbedUnimplementedSearchServiceServer() +} + +// UnimplementedSearchServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedSearchServiceServer struct{} + +func (UnimplementedSearchServiceServer) Search(context.Context, *SearchRequest) (*SearchResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Search not implemented") +} +func (UnimplementedSearchServiceServer) mustEmbedUnimplementedSearchServiceServer() {} +func (UnimplementedSearchServiceServer) testEmbeddedByValue() {} + +// UnsafeSearchServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to SearchServiceServer will +// result in compilation errors. +type UnsafeSearchServiceServer interface { + mustEmbedUnimplementedSearchServiceServer() +} + +func RegisterSearchServiceServer(s grpc.ServiceRegistrar, srv SearchServiceServer) { + // If the following call panics, it indicates UnimplementedSearchServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&SearchService_ServiceDesc, srv) +} + +func _SearchService_Search_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SearchRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SearchServiceServer).Search(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: SearchService_Search_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SearchServiceServer).Search(ctx, req.(*SearchRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// SearchService_ServiceDesc is the grpc.ServiceDesc for SearchService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var SearchService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "search.SearchService", + HandlerType: (*SearchServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Search", + Handler: _SearchService_Search_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proto/search.proto", +} diff --git a/grpc-protoscope/server/main.go b/grpc-protoscope/server/main.go new file mode 100644 index 00000000..c45c9f50 --- /dev/null +++ b/grpc-protoscope/server/main.go @@ -0,0 +1,179 @@ +package main + +import ( + "context" + "fmt" + "log" + "math" + "math/rand" + "net" + + pb "zepto-grpc/searchpb" + + "google.golang.org/grpc" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" +) + +// buildFacetEntry builds a FacetEntry wire: { 1: name, 2: { FacetValue } } +func buildFacetEntry(name string, numericVal *float64, textVal *string) []byte { + var fv []byte // FacetValue oneof + if numericVal != nil { + fv = protowire.AppendTag(fv, 2, protowire.Fixed64Type) + fv = protowire.AppendFixed64(fv, math.Float64bits(*numericVal)) + } else if textVal != nil { + fv = protowire.AppendTag(fv, 3, protowire.BytesType) + fv = protowire.AppendString(fv, *textVal) + } + var entry []byte + entry = protowire.AppendTag(entry, 1, protowire.BytesType) + entry = protowire.AppendString(entry, name) + entry = protowire.AppendTag(entry, 2, protowire.BytesType) + entry = protowire.AppendBytes(entry, fv) + return entry +} + +// buildResponse constructs SearchResponse wire bytes with randomized +// repeated-field ordering inside the facet buckets. +func buildResponse() []byte { + var buf []byte + + // field 1: float score = 67.0 + buf = protowire.AppendTag(buf, 1, protowire.Fixed32Type) + buf = protowire.AppendFixed32(buf, math.Float32bits(67.0)) + + // field 4: string hits_json + hitsJSON := `{"hits":[{"_index":"pvid_search_products_v4","_score":15100000000000000000,` + + `"_source":{"_rankingInfo":{"typosPresent":true,"numberOfWordsMatched":1}},` + + `"match_type":"Other","attributes":{"subThemes":null},` + + `"_id":"4f30407c-6a3c-4a4e-8a3d-652217d4b6cb_d67c25f8-3adb-40c1-9113-b46d54a6e8aa",` + + `"trimming_meta":{"trimming_type":"L3"}}]}` + buf = protowire.AppendTag(buf, 4, protowire.BytesType) + buf = protowire.AppendString(buf, hitsJSON) + + // field 8: int32 total = 0 + buf = protowire.AppendTag(buf, 8, protowire.VarintType) + buf = protowire.AppendVarint(buf, 0) + + // --- field 9: FacetInfo message --- + + // Build availability bucket entries (field 3 inside FacetInfo) + zero := 0.0 + ovs := "OVS" + availEntries := [][]byte{ + buildFacetEntry("candidateCnt", &zero, nil), + buildFacetEntry("type", nil, &ovs), + } + // RANDOMIZE repeated entries — triggers the bug + rand.Shuffle(len(availEntries), func(i, j int) { + availEntries[i], availEntries[j] = availEntries[j], availEntries[i] + }) + var availBucket []byte + for _, e := range availEntries { + availBucket = protowire.AppendTag(availBucket, 1, protowire.BytesType) + availBucket = protowire.AppendBytes(availBucket, e) + } + + // Build pricing bucket entries (field 2 inside FacetInfo) + one := 1.0 + pricingEntries := [][]byte{ + buildFacetEntry("candidateCnt", &one, nil), + buildFacetEntry("resultCnt", &one, nil), + } + rand.Shuffle(len(pricingEntries), func(i, j int) { + pricingEntries[i], pricingEntries[j] = pricingEntries[j], pricingEntries[i] + }) + var pricingBucket []byte + for _, e := range pricingEntries { + pricingBucket = protowire.AppendTag(pricingBucket, 1, protowire.BytesType) + pricingBucket = protowire.AppendBytes(pricingBucket, e) + } + + // Assemble FacetInfo: field 3 = availability, field 2 = pricing + var facetInfo []byte + facetInfo = protowire.AppendTag(facetInfo, 3, protowire.BytesType) + facetInfo = protowire.AppendBytes(facetInfo, availBucket) + facetInfo = protowire.AppendTag(facetInfo, 2, protowire.BytesType) + facetInfo = protowire.AppendBytes(facetInfo, pricingBucket) + + buf = protowire.AppendTag(buf, 9, protowire.BytesType) + buf = protowire.AppendBytes(buf, facetInfo) + + return buf +} + +// rawCodec sends pre-built wire bytes without proto re-marshaling. +type rawCodec struct{} + +func (rawCodec) Name() string { return "proto" } +func (rawCodec) Marshal(v interface{}) ([]byte, error) { + if b, ok := v.(*rawFrame); ok { + return b.data, nil + } + msg, ok := v.(proto.Message) + if !ok { + return nil, fmt.Errorf("rawCodec.Marshal: expected *rawFrame or proto.Message, got %T", v) + } + return proto.Marshal(msg) +} +func (rawCodec) Unmarshal(data []byte, v interface{}) error { + if b, ok := v.(*rawFrame); ok { + b.data = append(b.data[:0], data...) + return nil + } + msg, ok := v.(proto.Message) + if !ok { + return fmt.Errorf("rawCodec.Unmarshal: expected *rawFrame or proto.Message, got %T", v) + } + return proto.Unmarshal(data, msg) +} + +type rawFrame struct{ data []byte } + +func main() { + lis, err := net.Listen("tcp", ":50051") + if err != nil { + log.Fatalf("failed to listen on :50051: %v (check if the port is already in use)", err) + } + + s := grpc.NewServer(grpc.ForceServerCodec(rawCodec{})) + + // Register service with a custom handler that returns raw wire bytes + // with randomized field ordering each time. + sd := grpc.ServiceDesc{ + ServiceName: "search.SearchService", + HandlerType: (*pb.SearchServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Search", + Handler: func(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + req := new(pb.SearchRequest) + if err := dec(req); err != nil { + return nil, err + } + handler := func(ctx context.Context, req any) (any, error) { + searchReq := req.(*pb.SearchRequest) + log.Printf("Received search query: %s", searchReq.GetQuery()) + return &rawFrame{data: buildResponse()}, nil + } + if interceptor == nil { + return handler(ctx, req) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/search.SearchService/Search", + } + return interceptor(ctx, req, info, handler) + }, + }, + }, + } + s.RegisterService(&sd, &struct { + pb.UnimplementedSearchServiceServer + }{}) + + fmt.Println("gRPC server listening on :50051") + if err := s.Serve(lis); err != nil { + log.Fatalf("gRPC server stopped: %v", err) + } +}