Skip to content

Commit 5c08758

Browse files
authored
Updated streaming code (#64)
* Updated streaming code * Removed version
1 parent 0164bc0 commit 5c08758

11 files changed

Lines changed: 654 additions & 99 deletions

File tree

Makefile

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@ GO := $(shell which go)
33
DOCKER := $(shell which docker)
44

55
# Build flags
6-
BUILD_MODULE := $(shell cat go.mod | head -1 | cut -d ' ' -f 2)
7-
BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitSource=${BUILD_MODULE}
8-
BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitTag=$(shell git describe --tags --always)
9-
BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitBranch=$(shell git name-rev HEAD --name-only --always)
10-
BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitHash=$(shell git rev-parse HEAD)
11-
BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GoBuildTime=$(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
6+
BUILD_MODULE = $(shell cat go.mod | head -1 | cut -d ' ' -f 2)
7+
VERSION_PACKAGE := github.com/mutablelogic/go-server/pkg/version
8+
BUILD_LD_FLAGS += -X $(VERSION_PACKAGE).GitTag=$(shell git describe --tags --always)
9+
BUILD_LD_FLAGS += -X $(VERSION_PACKAGE).GitBranch=$(shell git name-rev HEAD --name-only --always)
1210
BUILD_FLAGS = -ldflags "-s -w $(BUILD_LD_FLAGS)"
1311

1412
# Set OS and Architecture

README.md

Lines changed: 139 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ modify each individual request when using the `Do` method:
202202
function to process a streaming text response of type `text/event-stream`, where
203203
`TextStreamCallback` is `func(TextStreamEvent) error`. See below for more details.
204204
* `OptJsonStreamCallback(fn JsonStreamCallback)` allows you to set a callback for JSON streaming
205-
responses, where `JsonStreamCallback` is `func(any) error`. See below for more details.
205+
responses, where `JsonStreamCallback` is `func(json.RawMessage) error`. See below for more details.
206206

207207
## Authentication
208208

@@ -334,7 +334,7 @@ func Callback(event client.TextStreamEvent) error {
334334

335335
// Decode the data into a JSON object
336336
var data map[string]any
337-
if err := event.Json(data); err != nil {
337+
if err := event.Json(&data); err != nil {
338338
return err
339339
}
340340

@@ -369,19 +369,149 @@ if err := stream.Decode(r, callback); err != nil {
369369
}
370370
```
371371

372-
When the `Accept` type of the payload is `text/event-stream` or `application/x-ndjson`, the client
373-
automatically adds `Cache-Control: no-cache` and `X-Accel-Buffering: no` request headers to
374-
prevent intermediate proxies (including Nginx) from buffering the stream.
372+
## JSON Streaming
375373

376-
## JSON Streaming Responses
374+
The client supports both one-way NDJSON response streaming and bi-directional NDJSON channels.
377375

378-
The client decodes JSON streaming responses by passing a callback function to the `OptJsonStreamCallback()` option.
379-
The callback with signature `func(any) error` is called for each JSON object in the stream, where the argument
380-
is the same type as the object in the request.
376+
### JSON Streaming Responses
377+
378+
For one-way JSON streaming responses, pass a callback function to the `OptJsonStreamCallback()` option.
379+
The callback with signature `func(json.RawMessage) error` is called for each JSON object in the stream.
380+
Decode the raw frame into the concrete type you expect.
381+
382+
```go
383+
package main
384+
385+
import (
386+
"encoding/json"
387+
"io"
388+
"log"
389+
"net/http"
390+
391+
client "github.com/mutablelogic/go-client"
392+
)
393+
394+
type Event struct {
395+
Value int `json:"value"`
396+
}
397+
398+
func main() {
399+
c, err := client.New(client.OptEndpoint("https://api.example.com"))
400+
if err != nil {
401+
log.Fatal(err)
402+
}
403+
404+
err = c.Do(
405+
client.NewRequestEx(http.MethodGet, client.ContentTypeJsonStream),
406+
nil,
407+
client.OptPath("events"),
408+
client.OptNoTimeout(),
409+
client.OptJsonStreamCallback(func(v json.RawMessage) error {
410+
var event Event
411+
if err := json.Unmarshal(v, &event); err != nil {
412+
return err
413+
}
414+
log.Printf("value=%d", event.Value)
415+
if event.Value >= 100 {
416+
return io.EOF
417+
}
418+
return nil
419+
}),
420+
)
421+
if err != nil {
422+
log.Fatal(err)
423+
}
424+
}
425+
```
381426

382427
You can return an error from the callback to stop the stream and return the error, or return `io.EOF` to stop the stream
383428
immediately and return success.
384429

430+
### Bi-Directional JSON Streams
431+
432+
Use `Client.Stream(ctx, opts...)` to open a bi-directional JSON stream. The returned `JSONStream`
433+
lets you send newline-delimited JSON request frames with `Send`, receive response frames with `Recv`,
434+
close the outbound side with `CloseSend`, and tear down both sides with `Close`.
435+
436+
Blank response lines are treated as keep-alive heartbeats and `Recv` returns `nil, nil` for them.
437+
438+
```go
439+
package main
440+
441+
import (
442+
"context"
443+
"encoding/json"
444+
"io"
445+
"log"
446+
"time"
447+
448+
client "github.com/mutablelogic/go-client"
449+
)
450+
451+
type Reply struct {
452+
Echo string `json:"echo"`
453+
}
454+
455+
func main() {
456+
ctx, cancel := context.WithCancel(context.Background())
457+
defer cancel()
458+
459+
// Create a new client
460+
c, err := client.New(client.OptEndpoint("https://api.example.com"))
461+
if err != nil {
462+
log.Fatal(err)
463+
}
464+
465+
// Create a bi-directional JSON stream to a server
466+
stream, err := c.Stream(ctx,client.OptPath("session", "1234", "channel"),client.OptNoTimeout())
467+
if err != nil {
468+
log.Fatal(err)
469+
}
470+
defer stream.Close()
471+
472+
// Background goroutine to send JSON frames every 5 seconds until the context is cancelled
473+
go func() {
474+
ticker := time.NewTicker(5 * time.Second)
475+
defer ticker.Stop()
476+
477+
for {
478+
select {
479+
case <-ctx.Done():
480+
_ = stream.CloseSend()
481+
return
482+
case <-ticker.C:
483+
frame := json.RawMessage(`{"text":"status"}`)
484+
if err := stream.Send(frame); err != nil {
485+
log.Printf("send error: %v", err)
486+
cancel()
487+
return
488+
}
489+
}
490+
}
491+
}()
492+
493+
// Foreground loop to receive JSON frames until the stream is closed or an error occurs
494+
for {
495+
frame, err := stream.Recv()
496+
if err == io.EOF {
497+
break
498+
}
499+
if err != nil {
500+
log.Fatal(err)
501+
}
502+
if len(frame) == 0 {
503+
continue // keep-alive heartbeat
504+
}
505+
506+
var reply Reply
507+
if err := json.Unmarshal(frame, &reply); err != nil {
508+
log.Fatal(err)
509+
}
510+
log.Printf("reply=%s", reply.Echo)
511+
}
512+
}
513+
```
514+
385515
## Transport Middleware
386516

387517
The `pkg/transport` package provides composable `http.RoundTripper` middleware. All middleware

client.go

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ type ClientOpt func(*Client) error
5151

5252
// Callback for json stream events, return an error if you want to stop streaming
5353
// with an error and io.EOF if you want to stop streaming and return success
54-
type JsonStreamCallback func(v any) error
54+
type JsonStreamCallback func(json.RawMessage) error
5555

5656
///////////////////////////////////////////////////////////////////////////////
5757
// GLOBALS
@@ -212,7 +212,7 @@ func (client *Client) request(ctx context.Context, method, accept, mimetype stri
212212
// For SSE or NDJSON streams, disable caching and Nginx proxy buffering so
213213
// events are delivered immediately rather than held in intermediate buffers.
214214
// Accept may be a comma-separated list so use Contains rather than ==.
215-
if strings.Contains(accept, ContentTypeTextStream) || strings.Contains(accept, ContentTypeJsonStream) {
215+
if strings.Contains(accept, ContentTypeTextStream) || strings.Contains(accept, ContentTypeJsonStream) || strings.Contains(accept, types.ContentTypeJSONStream) {
216216
r.Header.Set("Cache-Control", "no-cache")
217217
r.Header.Set("X-Accel-Buffering", "no")
218218
}
@@ -367,8 +367,8 @@ func do(client *http.Client, req *http.Request, accept string, strict bool, out
367367
}
368368
}
369369

370-
// Return success if out is nil
371-
if out == nil {
370+
// Return success if nothing is expected from the response body.
371+
if out == nil && reqopts.jsonStreamCallback == nil && reqopts.textStreamCallback == nil {
372372
return nil
373373
}
374374

@@ -388,32 +388,39 @@ func do(client *http.Client, req *http.Request, accept string, strict bool, out
388388
}
389389
}
390390

391-
switch mimetype {
392-
case ContentTypeJson, ContentTypeJsonStream:
393-
// JSON decode is streamable
391+
switch {
392+
case mimetype == ContentTypeJson || isJSONStreamContentType(mimetype):
394393
dec := json.NewDecoder(response.Body)
395-
for {
396-
if err := dec.Decode(out); err == io.EOF {
397-
break
398-
} else if err != nil {
399-
return err
400-
} else if reqopts.jsonStreamCallback != nil {
401-
if err := reqopts.jsonStreamCallback(out); errors.Is(err, io.EOF) {
394+
if reqopts.jsonStreamCallback != nil {
395+
for {
396+
var raw json.RawMessage
397+
if err := dec.Decode(&raw); err == io.EOF {
398+
break
399+
} else if err != nil {
400+
return err
401+
} else if err := reqopts.jsonStreamCallback(raw); errors.Is(err, io.EOF) {
402402
break
403403
} else if err != nil {
404404
return err
405405
}
406406
}
407+
break
407408
}
408-
case ContentTypeTextStream:
409-
if err := NewTextStream().Decode(response.Body, reqopts.textStreamCallback); err != nil {
410-
return err
409+
if out == nil {
410+
return nil
411+
}
412+
for {
413+
if err := dec.Decode(out); err == io.EOF {
414+
break
415+
} else if err != nil {
416+
return err
417+
}
411418
}
412-
case ContentTypeTextXml, ContentTypeApplicationXml:
419+
case mimetype == ContentTypeTextXml || mimetype == ContentTypeApplicationXml:
413420
if err := xml.NewDecoder(response.Body).Decode(out); err != nil {
414421
return err
415422
}
416-
case ContentTypeTextPlain:
423+
case mimetype == ContentTypeTextPlain:
417424
data, err := io.ReadAll(response.Body)
418425
if err != nil {
419426
return err

cmd/api/flags.go

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,13 @@ import (
88
"os"
99
"path/filepath"
1010
"regexp"
11-
"runtime"
1211
"strconv"
1312
"strings"
1413
"time"
1514

1615
// Packages
1716
"github.com/mutablelogic/go-client"
18-
"github.com/mutablelogic/go-client/pkg/version"
17+
"github.com/mutablelogic/go-server/pkg/version"
1918

2019
// Namespace imports
2120
. "github.com/djthorpe/go-errors"
@@ -204,29 +203,7 @@ func (flags *Flags) Cmd() *Cmd {
204203

205204
// PrintVersion prints the version of the application
206205
func (flags *Flags) PrintVersion() {
207-
w := flags.Output()
208-
fmt.Fprintf(w, "%v", flags.Name())
209-
if version.GitSource != "" {
210-
if version.GitTag != "" {
211-
fmt.Fprintf(w, " %v", version.GitTag)
212-
}
213-
if version.GitSource != "" {
214-
fmt.Fprintf(w, " (%v)", version.GitSource)
215-
}
216-
fmt.Fprintln(w, "")
217-
}
218-
if runtime.Version() != "" {
219-
fmt.Fprintf(w, "%v %v/%v\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
220-
}
221-
if version.GitBranch != "" {
222-
fmt.Fprintf(w, "Branch: %v\n", version.GitBranch)
223-
}
224-
if version.GitHash != "" {
225-
fmt.Fprintf(w, "Hash: %v\n", version.GitHash)
226-
}
227-
if version.GoBuildTime != "" {
228-
fmt.Fprintf(w, "BuildTime: %v\n", version.GoBuildTime)
229-
}
206+
fmt.Fprintln(flags.Output(), string(version.JSON(flags.Name())))
230207
}
231208

232209
// PrintUsage prints the usage of the application

go.mod

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ require (
77
github.com/andreburgaud/crypt2go v1.8.0
88
github.com/djthorpe/go-errors v1.0.3
99
github.com/djthorpe/go-tablewriter v0.0.11
10-
github.com/mutablelogic/go-server v1.6.22
10+
github.com/mutablelogic/go-server v1.6.23
1111
github.com/stretchr/testify v1.11.1
1212
github.com/xdg-go/pbkdf2 v1.0.0
1313
go.opentelemetry.io/otel v1.43.0
@@ -16,8 +16,8 @@ require (
1616
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
1717
go.opentelemetry.io/otel/sdk v1.43.0
1818
go.opentelemetry.io/otel/trace v1.43.0
19-
golang.org/x/crypto v0.49.0
20-
golang.org/x/term v0.41.0
19+
golang.org/x/crypto v0.50.0
20+
golang.org/x/term v0.42.0
2121
)
2222

2323
require (
@@ -29,16 +29,16 @@ require (
2929
github.com/go-logr/stdr v1.2.2 // indirect
3030
github.com/google/uuid v1.6.0 // indirect
3131
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
32-
github.com/mattn/go-runewidth v0.0.22 // indirect
32+
github.com/mattn/go-runewidth v0.0.23 // indirect
3333
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
3434
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
3535
go.opentelemetry.io/otel/metric v1.43.0 // indirect
3636
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
37-
golang.org/x/net v0.52.0 // indirect
38-
golang.org/x/sys v0.42.0 // indirect
39-
golang.org/x/text v0.35.0 // indirect
40-
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
41-
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
37+
golang.org/x/net v0.53.0 // indirect
38+
golang.org/x/sys v0.43.0 // indirect
39+
golang.org/x/text v0.36.0 // indirect
40+
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect
41+
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
4242
google.golang.org/grpc v1.80.0 // indirect
4343
google.golang.org/protobuf v1.36.11 // indirect
4444
gopkg.in/yaml.v3 v3.0.1 // indirect

0 commit comments

Comments
 (0)