Compare commits

...

21 Commits
master ... v4

Author SHA1 Message Date
vtolstov
c4d90c8a2b Apply Code Coverage Badge 2025-06-08 14:11:25 +00:00
2b3c413adc fixup grpc error codes in unary and stream processing
All checks were successful
sync / sync (push) Successful in 26s
coverage / build (push) Successful in 1m51s
test / test (push) Successful in 2m40s
Signed-off-by: Vasiliy Tolstov <v.tolstov@unistack.org>
2025-06-08 11:16:43 +03:00
20fb19fee9 changed embedded mutex to private field (#278)
Some checks failed
coverage / build (push) Successful in 2m26s
test / test (push) Failing after 16m18s
sync / sync (push) Successful in 8s
2025-05-14 01:25:57 +03:00
8e3c56f4ed update ci (#277)
All checks were successful
sync / sync (push) Successful in 10s
2025-05-05 19:19:33 +03:00
fcbae6f94a added commit hash check to avoid unnecessary repository cloning (#275)
All checks were successful
sync / sync (push) Successful in 10s
2025-05-05 13:44:43 +03:00
2f818d389b Merge branch 'v4' of https://git.unistack.org/unistack-org/micro-server-grpc into v4
All checks were successful
sync / sync (push) Successful in 14s
2025-05-04 15:02:46 +03:00
d55cb59531 fixup sync
Signed-off-by: Vasiliy Tolstov <v.tolstov@unistack.org>
2025-05-04 15:02:02 +03:00
8c416b10ef Обновить .github/workflows/job_sync.yml
Some checks failed
sync / sync (push) Has been cancelled
2025-05-02 18:55:11 +03:00
d3c2ae5f54 convert headers to HTTP/2 format before SendHeader() (#273)
All checks were successful
coverage / build (push) Has been skipped
test / test (push) Successful in 2m19s
2025-05-02 18:51:30 +03:00
3bb8ec0753 Обновить .github/workflows/job_sync.yml
All checks were successful
sync / sync (push) Has been skipped
2025-05-01 22:07:43 +03:00
27bca35eb6 Обновить .github/workflows/job_sync.yml
All checks were successful
sync / sync (push) Has been skipped
2025-05-01 22:01:33 +03:00
498218912c Обновить .github/workflows/job_sync.yml
All checks were successful
sync / sync (push) Successful in 23s
2025-05-01 21:59:18 +03:00
bd04f5b9cb Обновить .github/workflows/job_sync.yml 2025-05-01 21:58:56 +03:00
dc976006ad Обновить .github/workflows/job_sync.yml
All checks were successful
sync / sync (push) Has been skipped
2025-05-01 21:05:02 +03:00
934ebf6c0a fix uninitialized response metadata for incoming context (#264)
All checks were successful
coverage / build (push) Has been skipped
test / test (push) Successful in 2m8s
sync / sync (push) Successful in 18s
2025-05-01 20:42:59 +03:00
6d8fce53dd update ci (#265) 2025-05-01 20:42:52 +03:00
f12f3fb2c2 Обновить .github/workflows/job_coverage.yml
All checks were successful
sync / sync (push) Successful in 15s
2025-05-01 20:34:33 +03:00
vtolstov
5a755437c9 Apply Code Coverage Badge 2025-04-29 15:43:08 +00:00
05db1f3dae [v4] fix ci pipeline (#260)
All checks were successful
coverage / build (push) Successful in 1m54s
test / test (push) Successful in 2m42s
* attempt to fix coverage job

* Apply Code Coverage Badge

---------

Co-authored-by: pugnack <pugnack@users.noreply.github.com>
2025-04-29 18:39:27 +03:00
e4ba134fa6 [v4] breaking change: modify API for working with response metadata (#255)
Some checks failed
coverage / build (push) Failing after 40s
test / test (push) Successful in 3m3s
sync / sync (push) Successful in 17s
* implement functions to append/get metadata

* сhanged behavior to return nil instead of empty metadata for getResponseMetadata()

* removed metadata copy when passing to gRPC headers

---------

Co-authored-by: Vasiliy Tolstov <v.tolstov@unistack.org>
2025-04-29 18:34:11 +03:00
8a85989b79 update all
Signed-off-by: Vasiliy Tolstov <v.tolstov@unistack.org>
2025-04-29 18:30:08 +03:00
8 changed files with 297 additions and 56 deletions

View File

@@ -8,8 +8,6 @@ on:
- '.gitea/**'
pull_request:
branches: [ main, v3, v4 ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
jobs:

View File

@@ -20,7 +20,7 @@ jobs:
uses: actions/setup-go@v5
with:
cache-dependency-path: "**/*.sum"
go-version: 'stable'
go-version: 'stable'
- name: setup deps
run: go get -v ./...
- name: run lint

View File

@@ -3,11 +3,6 @@ name: sync
on:
schedule:
- cron: '*/5 * * * *'
# push:
# branches: [ master, v3, v4 ]
# paths-ignore:
# - '.github/**'
# - '.gitea/**'
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -23,35 +18,77 @@ jobs:
echo "machine git.unistack.org login vtolstov password ${{ secrets.TOKEN_GITEA }}" >> /root/.netrc
echo "machine github.com login vtolstov password ${{ secrets.TOKEN_GITHUB }}" >> /root/.netrc
- name: check master
id: check_master
run: |
src_hash=$(git ls-remote https://github.com/${GITHUB_REPOSITORY} refs/heads/master | cut -f1)
dst_hash=$(git ls-remote ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY} refs/heads/master | cut -f1)
echo "src_hash=$src_hash"
echo "dst_hash=$dst_hash"
if [ "$src_hash" != "$dst_hash" ]; then
echo "sync_needed=true" >> $GITHUB_OUTPUT
else
echo "sync_needed=false" >> $GITHUB_OUTPUT
fi
- name: sync master
if: steps.check_master.outputs.sync_needed == 'true'
run: |
git clone --filter=blob:none --filter=tree:0 --branch master --single-branch ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY} repo
cd repo
git remote add --no-tags --track master upstream https://github.com/${GITHUB_REPOSITORY}
git remote add --no-tags --fetch --track master upstream https://github.com/${GITHUB_REPOSITORY}
git pull --rebase upstream master
git push upstream master --progress
git push origin master --progress
cd ../
rm -rf repo
- name: check v3
id: check_v3
run: |
src_hash=$(git ls-remote https://github.com/${GITHUB_REPOSITORY} refs/heads/v3 | cut -f1)
dst_hash=$(git ls-remote ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY} refs/heads/v3 | cut -f1)
echo "src_hash=$src_hash"
echo "dst_hash=$dst_hash"
if [ "$src_hash" != "$dst_hash" ]; then
echo "sync_needed=true" >> $GITHUB_OUTPUT
else
echo "sync_needed=false" >> $GITHUB_OUTPUT
fi
- name: sync v3
if: steps.check_v3.outputs.sync_needed == 'true'
run: |
git clone --filter=blob:none --filter=tree:0 --branch v3 --single-branch ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY} repo
cd repo
git remote add --no-tags --fetch --track v3 upstream https://github.com/${GITHUB_REPOSITORY}
git pull --rebase upstream v3
git push upstream v3
git push upstream v3 --progress
git push origin v3 --progress
cd ../
rm -rf repo
- name: check v4
id: check_v4
run: |
src_hash=$(git ls-remote https://github.com/${GITHUB_REPOSITORY} refs/heads/v4 | cut -f1)
dst_hash=$(git ls-remote ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY} refs/heads/v4 | cut -f1)
echo "src_hash=$src_hash"
echo "dst_hash=$dst_hash"
if [ "$src_hash" != "$dst_hash" ]; then
echo "sync_needed=true" >> $GITHUB_OUTPUT
else
echo "sync_needed=false" >> $GITHUB_OUTPUT
fi
- name: sync v4
if: steps.check_v4.outputs.sync_needed == 'true'
run: |
git clone --filter=blob:none --filter=tree:0 --branch v4 --single-branch ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY} repo
cd repo
git remote add --no-tags --fetch --track v4 upstream https://github.com/${GITHUB_REPOSITORY}
git pull --rebase upstream v4
git push upstream v4
git push upstream v4 --progress
git push origin v4 --progress
cd ../
rm -rf repo

4
README.md Normal file
View File

@@ -0,0 +1,4 @@
# GRPC Server
![Coverage](https://img.shields.io/badge/Coverage-3.4%25-red)
This plugin is a grpc server for micro.

3
go.mod
View File

@@ -5,6 +5,7 @@ go 1.23.0
toolchain go1.24.2
require (
github.com/stretchr/testify v1.10.0
go.unistack.org/micro/v4 v4.1.8
golang.org/x/net v0.39.0
google.golang.org/grpc v1.72.0
@@ -13,8 +14,10 @@ require (
require (
github.com/ash3in/uuidv8 v1.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/matoous/go-nanoid v1.5.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/spf13/cast v1.7.1 // indirect
go.unistack.org/micro-proto/v4 v4.1.0 // indirect
golang.org/x/sys v0.32.0 // indirect

106
grpc.go
View File

@@ -63,12 +63,12 @@ type Server struct {
rpc *rServer
opts server.Options
unknownHandler grpc.StreamHandler
sync.RWMutex
stateLive *atomic.Uint32
stateReady *atomic.Uint32
stateHealth *atomic.Uint32
started bool
registered bool
mu sync.RWMutex
stateLive *atomic.Uint32
stateReady *atomic.Uint32
stateHealth *atomic.Uint32
started bool
registered bool
// reflection bool
}
@@ -92,8 +92,8 @@ func newServer(opts ...server.Option) *Server {
}
func (g *Server) configure(opts ...server.Option) error {
g.Lock()
defer g.Unlock()
g.mu.Lock()
defer g.mu.Unlock()
for _, o := range opts {
o(&g.opts)
@@ -263,6 +263,7 @@ func (g *Server) handler(srv interface{}, stream grpc.ServerStream) error {
// create new context
ctx = metadata.NewIncomingContext(ctx, md)
ctx = metadata.NewOutgoingContext(ctx, metadata.New(0))
ctx = context.WithValue(ctx, rspMetadataKey{}, &rspMetadataVal{m: metadata.New(0)})
stream = &streamWrapper{ctx, stream}
@@ -397,13 +398,22 @@ func (g *Server) processRequest(ctx context.Context, stream grpc.ServerStream, s
statusDesc := ""
// execute the handler
appErr := fn(ctx, r, replyv.Interface())
if outmd, ok := metadata.FromOutgoingContext(ctx); ok {
if err = stream.SendHeader(gmetadata.MD(outmd.Copy())); err != nil {
if md := getResponseMetadata(ctx); len(md) > 0 {
if err = stream.SendHeader(md.AsHTTP2()); err != nil {
return err
}
}
if appErr != nil {
var err error
var errStatus *status.Status
var ok bool
errStatus, ok = status.FromError(appErr)
if ok {
return errStatus.Err()
}
if errStatus = status.FromContextError(appErr); errStatus.Code() != codes.Unknown {
return errStatus.Err()
}
switch verr := appErr.(type) {
case *errors.Error:
statusCode = microError(verr)
@@ -417,12 +427,10 @@ func (g *Server) processRequest(ctx context.Context, stream grpc.ServerStream, s
if err != nil {
return err
}
case (interface{ GRPCStatus() *status.Status }):
errStatus = verr.GRPCStatus()
default:
g.RLock()
g.mu.RLock()
config := g.opts
g.RUnlock()
g.mu.RUnlock()
if config.Logger.V(logger.ErrorLevel) {
config.Logger.Error(config.Context, "handler error will not be transferred properly, must return *errors.Error or proto.Message")
}
@@ -481,14 +489,22 @@ func (g *Server) processStream(ctx context.Context, stream grpc.ServerStream, se
statusDesc := ""
appErr := fn(ctx, r, ss)
if outmd, ok := metadata.FromOutgoingContext(ctx); ok {
if err := stream.SendHeader(gmetadata.MD(outmd.Copy())); err != nil {
if md := getResponseMetadata(ctx); len(md) > 0 {
if err := stream.SendHeader(md.AsHTTP2()); err != nil {
return err
}
}
if appErr != nil {
var err error
var errStatus *status.Status
var ok bool
errStatus, ok = status.FromError(appErr)
if ok {
return errStatus.Err()
}
if errStatus = status.FromContextError(appErr); errStatus.Code() != codes.Unknown {
return errStatus.Err()
}
switch verr := appErr.(type) {
case *errors.Error:
statusCode = microError(verr)
@@ -519,9 +535,9 @@ func (g *Server) processStream(ctx context.Context, stream grpc.ServerStream, se
}
func (g *Server) Options() server.Options {
g.RLock()
g.mu.RLock()
opts := g.opts
g.RUnlock()
g.mu.RUnlock()
return opts
}
@@ -544,10 +560,10 @@ func (g *Server) Handle(h server.Handler) error {
}
func (g *Server) Register() error {
g.RLock()
g.mu.RLock()
rsvc := g.rsvc
config := g.opts
g.RUnlock()
g.mu.RUnlock()
// if service already filled, reuse it and return early
if rsvc != nil {
@@ -562,7 +578,7 @@ func (g *Server) Register() error {
return err
}
g.RLock()
g.mu.RLock()
// Maps are ordered randomly, sort the keys for consistency
handlerList := make([]string, 0, len(g.handlers))
for n := range g.handlers {
@@ -572,11 +588,11 @@ func (g *Server) Register() error {
sort.Strings(handlerList)
g.RUnlock()
g.mu.RUnlock()
g.RLock()
g.mu.RLock()
registered := g.registered
g.RUnlock()
g.mu.RUnlock()
if !registered {
if config.Logger.V(logger.InfoLevel) {
@@ -594,8 +610,8 @@ func (g *Server) Register() error {
return nil
}
g.Lock()
defer g.Unlock()
g.mu.Lock()
defer g.mu.Unlock()
g.registered = true
g.rsvc = service
@@ -606,9 +622,9 @@ func (g *Server) Register() error {
func (g *Server) Deregister() error {
var err error
g.RLock()
g.mu.RLock()
config := g.opts
g.RUnlock()
g.mu.RUnlock()
service, err := server.NewRegisterService(g)
if err != nil {
@@ -623,27 +639,27 @@ func (g *Server) Deregister() error {
return err
}
g.Lock()
g.mu.Lock()
g.rsvc = nil
if !g.registered {
g.Unlock()
g.mu.Unlock()
return nil
}
g.registered = false
g.Unlock()
g.mu.Unlock()
return nil
}
func (g *Server) Start() error {
g.RLock()
g.mu.RLock()
if g.started {
g.RUnlock()
g.mu.RUnlock()
return nil
}
g.RUnlock()
g.mu.RUnlock()
config := g.Options()
@@ -673,12 +689,12 @@ func (g *Server) Start() error {
if config.Logger.V(logger.InfoLevel) {
config.Logger.Info(config.Context, "Server [grpc] Listening on "+ts.Addr().String())
}
g.Lock()
g.mu.Lock()
g.opts.Address = ts.Addr().String()
if len(g.opts.Advertise) == 0 {
g.opts.Advertise = ts.Addr().String()
}
g.Unlock()
g.mu.Unlock()
// use RegisterCheck func before register
// nolint: nestif
@@ -729,9 +745,9 @@ func (g *Server) Start() error {
select {
// register self on interval
case <-t.C:
g.RLock()
g.mu.RLock()
registered := g.registered
g.RUnlock()
g.mu.RUnlock()
rerr := g.opts.RegisterCheck(g.opts.Context)
// nolint: nestif
if rerr != nil && registered {
@@ -808,29 +824,29 @@ func (g *Server) Start() error {
}()
// mark the server as started
g.Lock()
g.mu.Lock()
g.started = true
g.Unlock()
g.mu.Unlock()
return nil
}
func (g *Server) Stop() error {
g.RLock()
g.mu.RLock()
if !g.started {
g.RUnlock()
g.mu.RUnlock()
return nil
}
g.RUnlock()
g.mu.RUnlock()
ch := make(chan error)
g.exit <- ch
err := <-ch
g.Lock()
g.mu.Lock()
g.rsvc = nil
g.started = false
g.Unlock()
g.mu.Unlock()
return err
}

47
metadata.go Normal file
View File

@@ -0,0 +1,47 @@
package grpc
import (
"context"
"go.unistack.org/micro/v4/metadata"
)
type (
rspMetadataKey struct{}
rspMetadataVal struct {
m metadata.Metadata
}
)
// AppendResponseMetadata adds metadata entries to metadata.Metadata stored in the context.
// It expects the context to contain a *rspMetadataVal value under the rspMetadataKey{} key.
// If the value is missing or invalid, the function does nothing.
//
// Note: this function is not thread-safe. Synchronization is required if used from multiple goroutines.
func AppendResponseMetadata(ctx context.Context, md metadata.Metadata) {
if md == nil {
return
}
val, ok := ctx.Value(rspMetadataKey{}).(*rspMetadataVal)
if !ok || val == nil || val.m == nil {
return
}
for key, values := range md {
val.m.Append(key, values...)
}
}
// getResponseMetadata retrieves the metadata.Metadata stored in the context.
//
// Note: this function is not thread-safe. Synchronization is required if used from multiple goroutines.
// If you plan to modify the returned metadata, make a full copy to avoid affecting shared state.
func getResponseMetadata(ctx context.Context) metadata.Metadata {
val, ok := ctx.Value(rspMetadataKey{}).(*rspMetadataVal)
if !ok || val == nil || val.m == nil {
return nil
}
return val.m
}

136
metadata_test.go Normal file
View File

@@ -0,0 +1,136 @@
package grpc
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.unistack.org/micro/v4/metadata"
)
func TestAppendResponseMetadata(t *testing.T) {
tests := []struct {
name string
ctx context.Context
md metadata.Metadata
expected context.Context
}{
{
name: "nil metadata",
ctx: context.WithValue(context.Background(), rspMetadataKey{}, &rspMetadataVal{m: metadata.Metadata{}}),
md: nil,
expected: context.WithValue(context.Background(), rspMetadataKey{}, &rspMetadataVal{m: metadata.Metadata{}}),
},
{
name: "empty metadata",
ctx: context.WithValue(context.Background(), rspMetadataKey{}, &rspMetadataVal{m: metadata.Metadata{}}),
md: metadata.Metadata{},
expected: context.WithValue(context.Background(), rspMetadataKey{}, &rspMetadataVal{m: metadata.Metadata{}}),
},
{
name: "context without response metadata key",
ctx: context.Background(),
md: metadata.Pairs("key1", "val1"),
expected: context.Background(),
},
{
name: "context with nil response metadata value",
ctx: context.WithValue(context.Background(), rspMetadataKey{}, nil),
md: metadata.Pairs("key1", "val1"),
expected: context.WithValue(context.Background(), rspMetadataKey{}, nil),
},
{
name: "context with incorrect type in response metadata value",
ctx: context.WithValue(context.Background(), rspMetadataKey{}, struct{}{}),
md: metadata.Pairs("key1", "val1"),
expected: context.WithValue(context.Background(), rspMetadataKey{}, struct{}{}),
},
{
name: "context with response metadata value, but nil metadata",
ctx: context.WithValue(context.Background(), rspMetadataKey{}, &rspMetadataVal{m: nil}),
md: metadata.Pairs("key1", "val1"),
expected: context.WithValue(context.Background(), rspMetadataKey{}, &rspMetadataVal{m: nil}),
},
{
name: "basic metadata append",
ctx: context.WithValue(context.Background(), rspMetadataKey{}, &rspMetadataVal{m: metadata.Metadata{}}),
md: metadata.Pairs("key1", "val1"),
expected: context.WithValue(context.Background(), rspMetadataKey{}, &rspMetadataVal{
m: metadata.Metadata{
"key1": []string{"val1"},
},
}),
},
{
name: "multiple values for same key",
ctx: context.WithValue(context.Background(), rspMetadataKey{}, &rspMetadataVal{m: metadata.Metadata{}}),
md: metadata.Pairs("key1", "val1", "key1", "val2"),
expected: context.WithValue(context.Background(), rspMetadataKey{}, &rspMetadataVal{
m: metadata.Metadata{
"key1": []string{"val1", "val2"},
},
}),
},
{
name: "multiple values for different keys",
ctx: context.WithValue(context.Background(), rspMetadataKey{}, &rspMetadataVal{m: metadata.Metadata{}}),
md: metadata.Pairs("key1", "val1", "key1", "val2", "key2", "val3", "key2", "val4", "key3", "val5"),
expected: context.WithValue(context.Background(), rspMetadataKey{}, &rspMetadataVal{
m: metadata.Metadata{
"key1": []string{"val1", "val2"},
"key2": []string{"val3", "val4"},
"key3": []string{"val5"},
},
}),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
AppendResponseMetadata(tt.ctx, tt.md)
require.Equal(t, tt.expected, tt.ctx)
})
}
}
func TestGetResponseMetadata(t *testing.T) {
tests := []struct {
name string
ctx context.Context
expected metadata.Metadata
}{
{
name: "context without response metadata key",
ctx: context.Background(),
expected: nil,
},
{
name: "context with nil response metadata value",
ctx: context.WithValue(context.Background(), rspMetadataKey{}, nil),
expected: nil,
},
{
name: "context with incorrect type in response metadata value",
ctx: context.WithValue(context.Background(), rspMetadataKey{}, &struct{}{}),
expected: nil,
},
{
name: "context with response metadata value, but nil metadata",
ctx: context.WithValue(context.Background(), rspMetadataKey{}, &rspMetadataVal{m: nil}),
expected: nil,
},
{
name: "valid metadata",
ctx: context.WithValue(context.Background(), rspMetadataKey{}, &rspMetadataVal{
m: metadata.Pairs("key1", "value1"),
}),
expected: metadata.Metadata{"key1": {"value1"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.expected, getResponseMetadata(tt.ctx))
})
}
}