Skip to content

Commit

Permalink
[RBR-1591] expose private model schema to be used by shared services (#…
Browse files Browse the repository at this point in the history
…121)

* [RBR-1591] expose private model schema to be used by shared services

* add test for API Fetcher

* use a result
  • Loading branch information
Jeff Haynie authored Sep 24, 2024
1 parent 8ae0ebc commit 88bba80
Show file tree
Hide file tree
Showing 3 changed files with 246 additions and 14 deletions.
14 changes: 0 additions & 14 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,6 @@ github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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=
github.com/googleapis/enterprise-certificate-proxy v0.3.3 h1:QRje2j5GZimBzlbhGA2V2QlGNgL8G6e+wGo/+/2bWI0=
github.com/googleapis/enterprise-certificate-proxy v0.3.3/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s=
Expand Down Expand Up @@ -153,8 +151,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
Expand All @@ -175,8 +171,6 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
Expand All @@ -200,8 +194,6 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
Expand All @@ -210,8 +202,6 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
Expand All @@ -229,8 +219,6 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.196.0 h1:k/RafYqebaIJBO3+SMnfEGtFVlvp5vSgqTUF54UN/zg=
google.golang.org/api v0.196.0/go.mod h1:g9IL21uGkYgvQ5BZg6BAtoGJQIm8r6EgaAbpNey5wBE=
google.golang.org/api v0.197.0 h1:x6CwqQLsFiA5JKAiGyGBjc2bNtHtLddhJCE2IKuhhcQ=
google.golang.org/api v0.197.0/go.mod h1:AuOuo20GoQ331nq7DquGHlU6d+2wN2fZ8O0ta60nRNw=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
Expand All @@ -251,8 +239,6 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c=
google.golang.org/grpc v1.66.0/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/grpc v1.66.1 h1:hO5qAXR19+/Z44hmvIM4dQFMSYX9XcWsByfoxutBpAM=
google.golang.org/grpc v1.66.1/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
Expand Down
159 changes: 159 additions & 0 deletions schema/schema.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package schema

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"sync"
"time"
)

const defaultExpireAfter = 30 * time.Minute

type Cache struct {
TTL int `json:"ttl"`
}

type Changefeed struct {
AppendFieldsToSubject []string `json:"appendFieldsToSubject"`
PartitionKeys []string `json:"partitionKeys"`
}

type Model struct {
Cache *Cache `json:"cache"`
Changefeed *Changefeed `json:"changefeed"`
ModelVersion string `json:"modelVersion"`
Public bool `json:"public"`
}

type Result struct {
Success bool `json:"success"`
Model *Model `json:"data"`
}

type Fetcher interface {
FetchTable(ctx context.Context, table string) (io.ReadCloser, error)
}

type ModelRegistry interface {
Get(ctx context.Context, table string) (*Model, error)
}

type APIFetcher struct {
URL string
APIKey string
}

var _ Fetcher = (*APIFetcher)(nil)

func (f *APIFetcher) FetchTable(ctx context.Context, table string) (io.ReadCloser, error) {
u, err := url.Parse(f.URL)
if err != nil {
return nil, err
}
u.Path = path.Join("v3/schema/private/schema", table)
u.RawQuery = url.Values{"apikey": {f.APIKey}}.Encode()

req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return nil, err
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch model: %s", resp.Status)
}

return resp.Body, nil
}

// NewAPIFetcher creates a new API fetcher using the provided URL and API key.
func NewAPIFetcher(url, apiKey string) *APIFetcher {
return &APIFetcher{URL: url, APIKey: apiKey}
}

type modelCache struct {
model *Model
fetched time.Time
}

type modelRegistry struct {
models map[string]*modelCache
lock sync.Mutex
fetcher Fetcher
expireAfter time.Duration
}

var _ ModelRegistry = (*modelRegistry)(nil)

func (r *modelRegistry) Get(ctx context.Context, table string) (*Model, error) {
r.lock.Lock()
defer r.lock.Unlock()

if cache, ok := r.models[table]; ok {
if time.Since(cache.fetched) < r.expireAfter {
return cache.model, nil
}
}

rc, err := r.fetcher.FetchTable(ctx, table)
if err != nil {
return nil, err
}
defer rc.Close()

var res Result
if err := json.NewDecoder(rc).Decode(&res); err != nil {
return nil, err
}

if !res.Success {
return nil, fmt.Errorf("failed to fetch model: %s", table)
}

item := &modelCache{
model: res.Model,
fetched: time.Now(),
}
r.models[table] = item

return item.model, nil
}

// NewModelRegistry creates a new model registry using the provided fetcher.
func NewModelRegistry(fetcher Fetcher, opts ...WithOption) ModelRegistry {
opt := &ModelRegistryOption{
ExpireAfter: defaultExpireAfter,
}

for _, fn := range opts {
fn(opt)
}

return &modelRegistry{
models: make(map[string]*modelCache),
fetcher: fetcher,
expireAfter: opt.ExpireAfter,
}
}

type ModelRegistryOption struct {
ExpireAfter time.Duration
}

type WithOption func(opt *ModelRegistryOption)

// WithExpireAfter sets the expire after duration for an item in the model registry.
func WithExpireAfter(expireAfter time.Duration) WithOption {
return func(opt *ModelRegistryOption) {
opt.ExpireAfter = expireAfter
}
}
87 changes: 87 additions & 0 deletions schema/schema_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package schema

import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"

cstr "github.com/shopmonkeyus/go-common/string"
"github.com/stretchr/testify/assert"
)

type testFetcher struct {
reader io.ReadCloser
err error
called bool
}

func (t *testFetcher) FetchTable(ctx context.Context, table string) (io.ReadCloser, error) {
t.called = true
return t.reader, t.err
}

func TestNewModelRegistry(t *testing.T) {
var m Model
m.Public = true
m.ModelVersion = "1234"
fetcher := &testFetcher{io.NopCloser(bytes.NewReader([]byte(cstr.JSONStringify(Result{Success: true, Model: &m})))), nil, false}
r := NewModelRegistry(fetcher)
model, err := r.Get(context.Background(), "table")
if err != nil {
t.Fatal(err)
}
assert.True(t, fetcher.called)
if model.Public != m.Public || model.ModelVersion != m.ModelVersion {
t.Fatalf("expected model to be %v, got %v", m, model)
}
fetcher.called = false
model, err = r.Get(context.Background(), "table")
if err != nil {
t.Fatal(err)
}
assert.False(t, fetcher.called)
if model.Public != m.Public || model.ModelVersion != m.ModelVersion {
t.Fatalf("expected model to be %v, got %v", m, model)
}
}

func TestNewModelRegistryWithAPIFetcher(t *testing.T) {
var m Model
m.Public = true
m.ModelVersion = "1234"
var called bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
assert.Equal(t, "/v3/schema/private/schema/table", r.URL.Path)
if r.URL.Query().Get("apikey") != "test" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, cstr.JSONStringify(Result{Success: true, Model: &m}))
}))
defer ts.Close()
fetcher := NewAPIFetcher(ts.URL, "test")
r := NewModelRegistry(fetcher)
model, err := r.Get(context.Background(), "table")
if err != nil {
t.Fatal(err)
}
assert.True(t, called)
if model.Public != m.Public || model.ModelVersion != m.ModelVersion {
t.Fatalf("expected model to be %v, got %v", m, model)
}
called = false
model, err = r.Get(context.Background(), "table")
if err != nil {
t.Fatal(err)
}
assert.False(t, called)
if model.Public != m.Public || model.ModelVersion != m.ModelVersion {
t.Fatalf("expected model to be %v, got %v", m, model)
}
}

0 comments on commit 88bba80

Please sign in to comment.