Skip to content

Commit dbc6ee3

Browse files
authored
story(issue-86): rpc implement json helpers (#90)
1 parent 0c2c502 commit dbc6ee3

File tree

5 files changed

+597
-1
lines changed

5 files changed

+597
-1
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
[![Go Reference](https://pkg.go.dev/badge/github.com/z5labs/humus.svg)](https://pkg.go.dev/github.com/z5labs/humus)
44
[![Go Report Card](https://goreportcard.com/badge/github.com/z5labs/humus)](https://goreportcard.com/report/github.com/z5labs/humus)
5-
![Coverage](https://img.shields.io/badge/Coverage-71.5%25-brightgreen)
5+
![Coverage](https://img.shields.io/badge/Coverage-76.3%25-brightgreen)
66
[![build](https://github.com/z5labs/humus/actions/workflows/build.yaml/badge.svg)](https://github.com/z5labs/humus/actions/workflows/build.yaml)
77

88
**humus one stop shop framework for all Z5Labs projects in Go.**

rest/rpc/empty_example_test.go

+10
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ func ExampleReturnNothing() {
5353
fmt.Println("expected HTTP 200 status code but got", resp.StatusCode)
5454
return
5555
}
56+
if resp.Header.Get("Content-Type") != "" {
57+
return
58+
}
5659

5760
// Output: hello world
5861
}
@@ -66,6 +69,7 @@ func (*msgResponse) Spec() (int, *openapi3.Response, error) {
6669
}
6770

6871
func (mr *msgResponse) WriteResponse(ctx context.Context, w http.ResponseWriter) error {
72+
w.Header().Set("Content-Type", "application/json")
6973
w.WriteHeader(http.StatusOK)
7074
enc := json.NewEncoder(w)
7175
return enc.Encode(mr)
@@ -95,6 +99,12 @@ func ExampleConsumeNothing() {
9599
return
96100
}
97101

102+
contentType := resp.Header.Get("Content-Type")
103+
if contentType != "application/json" {
104+
fmt.Println("expected Content-Type to be set to application/json instead of:", contentType)
105+
return
106+
}
107+
98108
var mr msgResponse
99109
dec := json.NewDecoder(resp.Body)
100110
err = dec.Decode(&mr)

rest/rpc/json.go

+149
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) 2025 Z5Labs and Contributors
2+
//
3+
// This software is released under the MIT License.
4+
// https://opensource.org/licenses/MIT
5+
6+
package rpc
7+
8+
import (
9+
"context"
10+
"encoding/json"
11+
"net/http"
12+
13+
"github.com/z5labs/humus/internal/ptr"
14+
"github.com/z5labs/humus/internal/try"
15+
16+
"github.com/swaggest/jsonschema-go"
17+
"github.com/swaggest/openapi-go/openapi3"
18+
"go.opentelemetry.io/otel"
19+
)
20+
21+
// ReturnJsonHandler
22+
type ReturnJsonHandler[Req, Resp any] struct {
23+
inner Handler[Req, Resp]
24+
}
25+
26+
// ReturnJson initializes a [ReturnJsonHandler].
27+
func ReturnJson[Req, Resp any](h Handler[Req, Resp]) *ReturnJsonHandler[Req, Resp] {
28+
return &ReturnJsonHandler[Req, Resp]{
29+
inner: h,
30+
}
31+
}
32+
33+
// JsonResponse
34+
type JsonResponse[T any] struct {
35+
inner *T
36+
}
37+
38+
// Spec implements the [TypedResponse] interface.
39+
func (*JsonResponse[T]) Spec() (int, *openapi3.Response, error) {
40+
var t T
41+
var reflector jsonschema.Reflector
42+
43+
jsonSchema, err := reflector.Reflect(t, jsonschema.InlineRefs)
44+
if err != nil {
45+
return 0, nil, err
46+
}
47+
48+
var schemaOrRef openapi3.SchemaOrRef
49+
schemaOrRef.FromJSONSchema(jsonSchema.ToSchemaOrBool())
50+
51+
spec := &openapi3.Response{
52+
Content: map[string]openapi3.MediaType{
53+
"application/json": {
54+
Schema: &schemaOrRef,
55+
},
56+
},
57+
}
58+
return http.StatusOK, spec, nil
59+
}
60+
61+
// WriteResponse implements the [ResponseWriter] interface.
62+
func (jr *JsonResponse[T]) WriteResponse(ctx context.Context, w http.ResponseWriter) error {
63+
_, span := otel.Tracer("rpc").Start(ctx, "JsonResponse.WriteResponse")
64+
defer span.End()
65+
66+
w.Header().Set("Content-Type", "application/json")
67+
w.WriteHeader(http.StatusOK)
68+
69+
enc := json.NewEncoder(w)
70+
return enc.Encode(jr.inner)
71+
}
72+
73+
// Handle implements the [Handler] interface.
74+
func (h *ReturnJsonHandler[Req, Resp]) Handle(ctx context.Context, req *Req) (*JsonResponse[Resp], error) {
75+
spanCtx, span := otel.Tracer("rpc").Start(ctx, "ReturnJsonHandler.Handle")
76+
defer span.End()
77+
78+
resp, err := h.inner.Handle(spanCtx, req)
79+
if err != nil {
80+
return nil, err
81+
}
82+
return &JsonResponse[Resp]{inner: resp}, nil
83+
}
84+
85+
// ConsumeJsonHandler
86+
type ConsumeJsonHandler[Req, Resp any] struct {
87+
inner Handler[Req, Resp]
88+
}
89+
90+
// ConsumeJson initializes a [ConsumeJsonHandler].
91+
func ConsumeJson[Req, Resp any](h Handler[Req, Resp]) *ConsumeJsonHandler[Req, Resp] {
92+
return &ConsumeJsonHandler[Req, Resp]{
93+
inner: h,
94+
}
95+
}
96+
97+
// JsonRequest
98+
type JsonRequest[T any] struct {
99+
inner T
100+
}
101+
102+
// Spec implements the [TypedRequest] interface.
103+
func (*JsonRequest[T]) Spec() (*openapi3.RequestBody, error) {
104+
var t T
105+
var reflector jsonschema.Reflector
106+
107+
jsonSchema, err := reflector.Reflect(t, jsonschema.InlineRefs)
108+
if err != nil {
109+
return nil, err
110+
}
111+
112+
var schemaOrRef openapi3.SchemaOrRef
113+
schemaOrRef.FromJSONSchema(jsonSchema.ToSchemaOrBool())
114+
115+
spec := &openapi3.RequestBody{
116+
Required: ptr.Ref(true),
117+
Content: map[string]openapi3.MediaType{
118+
"application/json": {
119+
Schema: &schemaOrRef,
120+
},
121+
},
122+
}
123+
return spec, nil
124+
}
125+
126+
// ReadRequest implements the [RequestReader] interface.
127+
func (jr *JsonRequest[T]) ReadRequest(ctx context.Context, r *http.Request) (err error) {
128+
_, span := otel.Tracer("rpc").Start(ctx, "JsonRequest.ReadRequest")
129+
defer span.End()
130+
defer try.Close(&err, r.Body)
131+
132+
contentType := r.Header.Get("Content-Type")
133+
if contentType != "application/json" {
134+
return InvalidContentTypeError{
135+
ContentType: contentType,
136+
}
137+
}
138+
139+
dec := json.NewDecoder(r.Body)
140+
return dec.Decode(&jr.inner)
141+
}
142+
143+
// Handle implements the [Handler] interface.
144+
func (h *ConsumeJsonHandler[Req, Resp]) Handle(ctx context.Context, req *JsonRequest[Req]) (*Resp, error) {
145+
spanCtx, span := otel.Tracer("rpc").Start(ctx, "ConsumeJsonHandler.Handle")
146+
defer span.End()
147+
148+
return h.inner.Handle(spanCtx, &req.inner)
149+
}

rest/rpc/json_example_test.go

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2025 Z5Labs and Contributors
2+
//
3+
// This software is released under the MIT License.
4+
// https://opensource.org/licenses/MIT
5+
6+
package rpc
7+
8+
import (
9+
"context"
10+
"encoding/json"
11+
"fmt"
12+
"net/http"
13+
"net/http/httptest"
14+
"strings"
15+
)
16+
17+
func ExampleReturnJson() {
18+
p := ProducerFunc[msgResponse](func(_ context.Context) (*msgResponse, error) {
19+
return &msgResponse{Msg: "hello world"}, nil
20+
})
21+
22+
h := ReturnJson(ConsumeNothing(p))
23+
24+
op := NewOperation(h)
25+
26+
srv := httptest.NewServer(op)
27+
defer srv.Close()
28+
29+
resp, err := http.Get(srv.URL)
30+
if err != nil {
31+
fmt.Println(err)
32+
return
33+
}
34+
defer resp.Body.Close()
35+
36+
if resp.StatusCode != http.StatusOK {
37+
fmt.Println("expected HTTP 200 status code but got", resp.StatusCode)
38+
return
39+
}
40+
41+
contentType := resp.Header.Get("Content-Type")
42+
if contentType != "application/json" {
43+
fmt.Println("expected Content-Type to be set to application/json instead of:", contentType)
44+
return
45+
}
46+
47+
var mr msgResponse
48+
dec := json.NewDecoder(resp.Body)
49+
err = dec.Decode(&mr)
50+
if err != nil {
51+
fmt.Println(err)
52+
return
53+
}
54+
55+
fmt.Println(mr.Msg)
56+
// Output: hello world
57+
}
58+
59+
func ExampleConsumeJson() {
60+
c := ConsumerFunc[msgRequest](func(_ context.Context, req *msgRequest) error {
61+
fmt.Println(req.Msg)
62+
return nil
63+
})
64+
65+
h := ConsumeJson(ReturnNothing(c))
66+
67+
op := NewOperation(h)
68+
69+
srv := httptest.NewServer(op)
70+
defer srv.Close()
71+
72+
resp, err := http.Post(srv.URL, "application/json", strings.NewReader(`{"msg":"hello world"}`))
73+
if err != nil {
74+
fmt.Println(err)
75+
return
76+
}
77+
if resp.StatusCode != http.StatusOK {
78+
fmt.Println("expected HTTP 200 status code but got", resp.StatusCode)
79+
return
80+
}
81+
if resp.Header.Get("Content-Type") != "" {
82+
return
83+
}
84+
85+
// Output: hello world
86+
}

0 commit comments

Comments
 (0)