👌 Refactored after pass websub.rocks tests

This commit is contained in:
Maxim Lebedev 2023-03-14 04:19:53 +06:00
parent ff0168205c
commit 75d1c86ea3
Signed by: toby3d
GPG Key ID: 1F14E25B7C119FC5
28 changed files with 764 additions and 405 deletions

View File

@ -5,6 +5,8 @@ const (
MIMEApplicationFormCharsetUTF8 = MIMEApplicationForm + "; " + charsetUTF8
MIMETextHTML = "text/html"
MIMETextHTMLCharsetUTF8 = MIMETextHTML + "; " + charsetUTF8
MIMETextPlain = "text/plain"
MIMETextPlainCharsetUTF8 = MIMETextPlain + "; " + charsetUTF8
charsetUTF8 = "charset=UTF-8"
)

View File

@ -1,37 +0,0 @@
package domain
import (
"fmt"
"net/url"
"source.toby3d.me/toby3d/hub/internal/common"
)
// Callback describes the URL at which a subscriber wishes to receive content
// distribution requests.
type Callback struct {
callback *url.URL
}
func ParseCallback(str string) (*Callback, error) {
u, err := url.Parse(str)
if err != nil {
return nil, fmt.Errorf("cannot parse string as callback URL: %w", err)
}
return &Callback{callback: u}, nil
}
func (c Callback) AddQuery(q url.Values) {
q.Add(common.HubCallback, c.callback.String())
}
func (c Callback) URL() *url.URL {
u, _ := url.Parse(c.callback.String())
return u
}
func (c Callback) String() string {
return c.callback.String()
}

View File

@ -1,7 +1,6 @@
package domain
import (
"bytes"
"crypto/rand"
"encoding/base64"
"fmt"
@ -11,7 +10,7 @@ import (
)
type Challenge struct {
challenge []byte
challenge string
}
func NewChallenge(length uint8) (*Challenge, error) {
@ -20,15 +19,15 @@ func NewChallenge(length uint8) (*Challenge, error) {
return nil, fmt.Errorf("cannot create a new challenge: %w", err)
}
return &Challenge{challenge: []byte(base64.URLEncoding.EncodeToString(src))}, nil
return &Challenge{challenge: base64.URLEncoding.EncodeToString(src)}, nil
}
func (c Challenge) AddQuery(q url.Values) {
q.Add(common.HubChallenge, string(c.challenge))
}
func (c Challenge) Equal(target []byte) bool {
return bytes.Equal(c.challenge, target)
func (c Challenge) Equal(target string) bool {
return c.challenge == target
}
func (c Challenge) String() string {

View File

@ -1,34 +0,0 @@
package domain
import (
"net/url"
"strconv"
"source.toby3d.me/toby3d/hub/internal/common"
)
// LeaseSeconds describes a number of seconds for which the subscriber would
// like to have the subscription active, given as a positive decimal integer.
// Hubs MAY choose to respect this value or not, depending on their own
// policies, and MAY set a default value if the subscriber omits the parameter.
// This parameter MAY be present for unsubscription requests and MUST be ignored
// by the hub in that case.
type LeaseSeconds struct {
leaseSeconds uint
}
func NewLeaseSeconds(raw uint) LeaseSeconds {
return LeaseSeconds{leaseSeconds: raw}
}
func (ls LeaseSeconds) AddQuery(q url.Values) {
if ls.leaseSeconds == 0 {
return
}
q.Add(common.HubLeaseSeconds, strconv.FormatUint(uint64(ls.leaseSeconds), 10))
}
func (ls LeaseSeconds) IsZero() bool {
return ls.leaseSeconds == 0
}

View File

@ -1,26 +0,0 @@
package domain
import (
"crypto/hmac"
"encoding/hex"
"hash"
"net/http"
"source.toby3d.me/toby3d/hub/internal/common"
)
type Push struct {
Self Topic
ContentType string
Content []byte
}
func (p Push) SetXHubSignatureHeader(req *http.Request, alg Algorithm, secret Secret) {
if alg == AlgorithmUnd || secret.secret == "" {
return
}
h := func() hash.Hash { return alg.Hash() }
req.Header.Set(common.HeaderXHubSignature, alg.algorithm+"="+hex.EncodeToString(hmac.New(h,
[]byte(secret.secret)).Sum(p.Content)))
}

View File

@ -1,46 +1,78 @@
package domain
import (
"math/rand"
"net/url"
"strconv"
"testing"
"time"
"source.toby3d.me/toby3d/hub/internal/common"
)
// Subscription is a unique relation to a topic by a subscriber that indicates
// it should receive updates for that topic.
type Subscription struct {
Topic Topic
Callback Callback
Secret Secret
LeaseSeconds LeaseSeconds
}
// First creation datetime
CreatedAt time.Time
func (s Subscription) SUID() SUID {
return NewSSID(s.Topic, s.Callback)
// Last updating datetime
UpdatedAt time.Time
// Datetime when subscription must be deleted
ExpiredAt time.Time
// Datetime synced with topic updating time
SyncedAt time.Time
Callback *url.URL
Topic *url.URL
Secret Secret
}
func (s Subscription) AddQuery(q url.Values) {
for _, w := range []QueryAdder{s.Callback, s.Topic, s.LeaseSeconds, s.Secret} {
w.AddQuery(q)
s.Secret.AddQuery(q)
q.Add(common.HubTopic, s.Topic.String())
q.Add(common.HubCallback, s.Callback.String())
q.Add(common.HubLeaseSeconds, strconv.FormatFloat(s.LeaseSeconds(), 'g', 0, 64))
}
func (s Subscription) SUID() SUID {
return SUID{
topic: s.Topic.String(),
callback: s.Callback.String(),
}
}
func (s Subscription) LeaseSeconds() float64 {
return s.ExpiredAt.Sub(s.UpdatedAt).Round(time.Second).Seconds()
}
func (s Subscription) Synced(t Topic) bool {
return s.SyncedAt.Equal(t.UpdatedAt) || s.SyncedAt.After(t.UpdatedAt)
}
func (s Subscription) Expired(ts time.Time) bool {
return s.ExpiredAt.Before(ts)
}
func TestSubscription(tb testing.TB, callbackUrl string) *Subscription {
tb.Helper()
callback, err := ParseCallback(callbackUrl)
callback, err := url.Parse(callbackUrl)
if err != nil {
tb.Fatal(err)
}
ts := time.Now().UTC().Round(time.Second)
secret := TestSecret(tb)
return &Subscription{
Topic: Topic{topic: &url.URL{
Scheme: "https",
Host: "example.com",
Path: "lipsum",
}},
Callback: *callback,
Secret: *TestSecret(tb),
LeaseSeconds: NewLeaseSeconds(uint(rand.Intn(60))),
CreatedAt: ts,
UpdatedAt: ts,
ExpiredAt: ts.Add(10 * 24 * time.Hour).Round(time.Second),
Callback: callback,
Topic: &url.URL{Scheme: "https", Host: "example.com", Path: "/lipsum"},
Secret: *secret,
}
}

View File

@ -1,29 +1,25 @@
package domain
import "net/url"
// SUID describes a subscription's unique key is the tuple ([Topic] URL,
// Subscriber [Callback] URL).
type SUID struct {
suid [2]string
topic string
callback string
}
func NewSSID(topic Topic, callback Callback) SUID {
func NewSSID(topic Topic, callback *url.URL) SUID {
return SUID{
suid: [2]string{topic.topic.String(), callback.callback.String()},
topic: topic.Self.String(),
callback: callback.String(),
}
}
func (suid SUID) Equal(target SUID) bool {
for i := range suid.suid {
if suid.suid[i] == target.suid[i] {
continue
}
return false
}
return true
return suid.topic == target.topic && suid.callback == target.callback
}
func (suid SUID) GoString() string {
return "domain.SUID(" + suid.suid[0] + ":" + suid.suid[1] + ")"
return "domain.SUID(" + suid.topic + ":" + suid.callback + ")"
}

View File

@ -1,38 +1,43 @@
package domain
import (
"fmt"
"net/url"
"testing"
"time"
"source.toby3d.me/toby3d/hub/internal/common"
)
// Topic is a HTTP [RFC7230] (or HTTPS [RFC2818]) resource URL. The unit to
// which one can subscribe to changes.
//
// [RFC7230]: https://tools.ietf.org/html/rfc7230
// [RFC2818]: https://tools.ietf.org/html/rfc2818
type Topic struct {
topic *url.URL
CreatedAt time.Time
UpdatedAt time.Time
Self *url.URL
ContentType string
Content []byte
}
func ParseTopic(str string) (*Topic, error) {
u, err := url.Parse(str)
if err != nil {
return nil, fmt.Errorf("cannot parse string as topic URL: %w", err)
}
func TestTopic(tb testing.TB) *Topic {
tb.Helper()
return &Topic{topic: u}, nil
now := time.Now().UTC().Add(-1 * time.Hour)
return &Topic{
CreatedAt: now,
UpdatedAt: now,
Self: &url.URL{Scheme: "https", Host: "example.com", Path: "/"},
ContentType: "text/html",
Content: []byte("hello, world"),
}
}
func (t Topic) AddQuery(q url.Values) {
q.Add(common.HubTopic, t.topic.String())
q.Add(common.HubTopic, t.Self.String())
}
func (t Topic) Equal(target Topic) bool {
return t.topic.String() == target.topic.String()
return t.Self.String() == target.Self.String()
}
func (t Topic) String() string {
return t.topic.String()
return t.Self.String()
}

View File

@ -1,6 +1,7 @@
package http
import (
"context"
"errors"
"fmt"
"net/http"
@ -14,27 +15,29 @@ import (
"source.toby3d.me/toby3d/hub/internal/domain"
"source.toby3d.me/toby3d/hub/internal/hub"
"source.toby3d.me/toby3d/hub/internal/subscription"
"source.toby3d.me/toby3d/hub/internal/topic"
"source.toby3d.me/toby3d/hub/web/template"
)
type (
Request struct {
Callback domain.Callback
Topic domain.Topic
Callback *url.URL
Topic *url.URL
Secret domain.Secret
Mode domain.Mode
LeaseSeconds domain.LeaseSeconds
LeaseSeconds float64
}
Response struct {
Mode domain.Mode
Topic domain.Topic
Reason string
Topic domain.Topic
}
NewHandlerParams struct {
Hub hub.UseCase
Subscriptions subscription.UseCase
Topics topic.UseCase
Matcher language.Matcher
Name string
}
@ -42,12 +45,13 @@ type (
Handler struct {
hub hub.UseCase
subscriptions subscription.UseCase
topics topic.UseCase
matcher language.Matcher
name string
}
)
var DefaultRequestLeaseSeconds = domain.NewLeaseSeconds(uint(time.Duration(time.Hour * 24 * 10).Seconds())) // 10 days
var DefaultRequestLeaseSeconds = time.Duration(10 * 24 * time.Hour).Seconds() // 10 days
var (
ErrHubMode = errors.New(common.HubMode + " MUST be " + domain.ModeSubscribe.String() + " or " +
@ -61,74 +65,71 @@ func NewHandler(params NewHandlerParams) *Handler {
matcher: params.Matcher,
name: params.Name,
subscriptions: params.Subscriptions,
topics: params.Topics,
}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC().Round(time.Second)
switch r.Method {
default:
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
case http.MethodPost:
req := NewRequest()
if err := req.bind(r); err != nil {
var err error
if err = req.bind(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// TODO(toby3d): send denied ping to callback if it's not accepted by hub
s := new(domain.Subscription)
req.populate(s)
req.populate(s, now)
switch req.Mode {
case domain.ModeSubscribe:
h.hub.Subscribe(r.Context(), *s)
case domain.ModeUnsubscribe:
h.hub.Unsubscribe(r.Context(), *s)
case domain.ModeSubscribe, domain.ModeUnsubscribe:
if _, err = h.hub.Verify(r.Context(), *s, req.Mode); err != nil {
r.Clone(context.WithValue(r.Context(), "error", err))
w.WriteHeader(http.StatusAccepted)
return
}
switch req.Mode {
case domain.ModeSubscribe:
_, err = h.subscriptions.Subscribe(r.Context(), *s)
case domain.ModeUnsubscribe:
_, err = h.subscriptions.Unsubscribe(r.Context(), *s)
}
case domain.ModePublish:
go h.hub.Publish(r.Context(), req.Topic)
_, err = h.topics.Publish(r.Context(), req.Topic)
}
if err != nil {
r.Clone(context.WithValue(r.Context(), "error", err))
}
w.WriteHeader(http.StatusAccepted)
case "", http.MethodGet:
tags, _, _ := language.ParseAcceptLanguage(r.Header.Get(common.HeaderAcceptLanguage))
tag, _, _ := h.matcher.Match(tags...)
baseOf := template.NewBaseOf(tag, h.name)
var page template.Page
if r.URL.Query().Has(common.HubTopic) {
topic, err := domain.ParseTopic(r.URL.Query().Get(common.HubTopic))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
subscriptions, err := h.subscriptions.Fetch(r.Context(), *topic)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
page = &template.Topic{
BaseOf: baseOf,
Subscribers: len(subscriptions),
}
} else {
page = &template.Home{BaseOf: baseOf}
}
w.Header().Set(common.HeaderContentType, common.MIMETextHTMLCharsetUTF8)
template.WriteTemplate(w, page)
template.WriteTemplate(w, &template.Home{BaseOf: template.NewBaseOf(tag, h.name)})
}
}
func NewRequest() *Request {
return &Request{
Mode: domain.ModeUnd,
Callback: domain.Callback{},
Callback: nil,
Secret: domain.Secret{},
Topic: domain.Topic{},
Topic: nil,
LeaseSeconds: DefaultRequestLeaseSeconds,
}
}
@ -148,54 +149,33 @@ func (r *Request) bind(req *http.Request) error {
return fmt.Errorf("cannot parse %s: %w", common.HubMode, err)
}
// NOTE(toby3d): hub.topic
if !req.PostForm.Has(common.HubTopic) {
return fmt.Errorf("%s parameter is required, but not provided", common.HubTopic)
}
if r.Topic, err = url.Parse(req.PostForm.Get(common.HubTopic)); err != nil {
return fmt.Errorf("cannot parse %s: %w", common.HubTopic, err)
}
switch r.Mode {
case domain.ModePublish:
if !req.PostForm.Has(common.HubURL) {
return fmt.Errorf("%s parameter for %s %s is required, but not provided", common.HubURL,
r.Mode, common.HubMode)
}
topic, err := domain.ParseTopic(req.PostForm.Get(common.HubURL))
if err != nil {
return fmt.Errorf("cannot parse %s: %w", common.HubTopic, err)
}
r.Topic = *topic
case domain.ModeSubscribe, domain.ModeUnsubscribe:
for _, k := range []string{common.HubTopic, common.HubCallback} {
if req.PostForm.Has(k) {
continue
}
return fmt.Errorf("%s parameter is required, but not provided", k)
}
// NOTE(toby3d): hub.topic
topic, err := domain.ParseTopic(req.PostForm.Get(common.HubTopic))
if err != nil {
return fmt.Errorf("cannot parse %s: %w", common.HubTopic, err)
}
r.Topic = *topic
// NOTE(toby3d): hub.callback
callback, err := domain.ParseCallback(req.PostForm.Get(common.HubCallback))
if err != nil {
if !req.PostForm.Has(common.HubCallback) {
return fmt.Errorf("%s parameter is required, but not provided", common.HubCallback)
}
if r.Callback, err = url.Parse(req.PostForm.Get(common.HubCallback)); err != nil {
return fmt.Errorf("cannot parse %s: %w", common.HubCallback, err)
}
r.Callback = *callback
// NOTE(toby3d): hub.lease_seconds
if r.Mode != domain.ModeUnsubscribe && req.PostForm.Has(common.HubLeaseSeconds) {
var ls uint64
if ls, err = strconv.ParseUint(req.PostForm.Get(common.HubLeaseSeconds), 10, 64); err != nil {
r.LeaseSeconds, err = strconv.ParseFloat(req.PostForm.Get(common.HubLeaseSeconds), 64)
if err != nil {
return fmt.Errorf("cannot parse %s: %w", common.HubLeaseSeconds, err)
}
if ls != 0 {
r.LeaseSeconds = domain.NewLeaseSeconds(uint(ls))
}
}
// NOTE(toby3d): hub.secret
@ -218,11 +198,13 @@ func (r *Request) bind(req *http.Request) error {
return nil
}
func (r Request) populate(s *domain.Subscription) {
func (r Request) populate(s *domain.Subscription, ts time.Time) {
s.CreatedAt = ts
s.UpdatedAt = ts
s.ExpiredAt = ts.Add(time.Duration(r.LeaseSeconds) * time.Second).Round(time.Second)
s.Callback = r.Callback
s.LeaseSeconds = r.LeaseSeconds
s.Secret = r.Secret
s.Topic = r.Topic
s.Secret = r.Secret
}
func NewResponse(t domain.Topic, err error) *Response {

View File

@ -2,7 +2,6 @@ package http_test
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
@ -10,15 +9,16 @@ import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"golang.org/x/text/language"
"source.toby3d.me/toby3d/hub/internal/common"
"source.toby3d.me/toby3d/hub/internal/domain"
delivery "source.toby3d.me/toby3d/hub/internal/hub/delivery/http"
ucase "source.toby3d.me/toby3d/hub/internal/hub/usecase"
"source.toby3d.me/toby3d/hub/internal/subscription"
hubucase "source.toby3d.me/toby3d/hub/internal/hub/usecase"
subscriptionmemoryrepo "source.toby3d.me/toby3d/hub/internal/subscription/repository/memory"
subscriptionucase "source.toby3d.me/toby3d/hub/internal/subscription/usecase"
topicmemoryrepo "source.toby3d.me/toby3d/hub/internal/topic/repository/memory"
topicucase "source.toby3d.me/toby3d/hub/internal/topic/usecase"
)
func TestHandler_ServeHTTP_Subscribe(t *testing.T) {
@ -31,22 +31,27 @@ func TestHandler_ServeHTTP_Subscribe(t *testing.T) {
in := domain.TestSubscription(t, srv.URL+"/lipsum")
subscriptions := subscriptionmemoryrepo.NewMemorySubscriptionRepository()
hub := ucase.NewHubUseCase(subscriptions, srv.Client(), &url.URL{Scheme: "https", Host: "hub.exmaple.com"})
topics := topicmemoryrepo.NewMemoryTopicRepository()
hub := hubucase.NewHubUseCase(topics, subscriptions, srv.Client(), &url.URL{
Scheme: "https",
Host: "hub.exmaple.com",
Path: "/",
})
payload := make(url.Values)
domain.ModeSubscribe.AddQuery(payload)
in.AddQuery(payload)
req := httptest.NewRequest(http.MethodPost, "https://hub.example.com/",
strings.NewReader(payload.Encode()))
req := httptest.NewRequest(http.MethodPost, "https://hub.example.com/", strings.NewReader(payload.Encode()))
req.Header.Set(common.HeaderContentType, common.MIMEApplicationFormCharsetUTF8)
w := httptest.NewRecorder()
delivery.NewHandler(delivery.NewHandlerParams{
Hub: hub,
Subscriptions: subscriptions,
Subscriptions: subscriptionucase.NewSubscriptionUseCase(subscriptions, topics),
Topics: topicucase.NewTopicUseCase(topics, srv.Client()),
Matcher: language.NewMatcher([]language.Tag{language.English}),
Name: "hub",
Name: "WebSub",
}).ServeHTTP(w, req)
resp := w.Result()
@ -54,49 +59,45 @@ func TestHandler_ServeHTTP_Subscribe(t *testing.T) {
if expect := http.StatusAccepted; resp.StatusCode != expect {
t.Errorf("%s %s = %d, want %d", req.Method, req.RequestURI, resp.StatusCode, expect)
}
out, err := subscriptions.Get(context.Background(), in.SUID())
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(out, in, cmp.AllowUnexported(domain.Secret{}, domain.Callback{}, domain.Topic{},
domain.LeaseSeconds{})); diff != "" {
t.Error(diff)
}
}
func TestHandler_ServeHTTP_Unsubscribe(t *testing.T) {
t.Parallel()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(common.HeaderContentType, common.MIMETextPlainCharsetUTF8)
fmt.Fprint(w, r.URL.Query().Get(common.HubChallenge))
}))
t.Cleanup(srv.Close)
in := domain.TestSubscription(t, srv.URL+"/lipsum")
subscriptions := subscriptionmemoryrepo.NewMemorySubscriptionRepository()
topics := topicmemoryrepo.NewMemoryTopicRepository()
if err := subscriptions.Create(context.Background(), in.SUID(), *in); err != nil {
t.Fatal(err)
}
hub := ucase.NewHubUseCase(subscriptions, srv.Client(), &url.URL{Scheme: "https", Host: "hub.exmaple.com"})
hub := hubucase.NewHubUseCase(topics, subscriptions, srv.Client(), &url.URL{
Scheme: "https",
Host: "hub.exmaple.com",
Path: "/",
})
payload := make(url.Values)
domain.ModeUnsubscribe.AddQuery(payload)
in.AddQuery(payload)
req := httptest.NewRequest(http.MethodPost, "https://hub.example.com/",
strings.NewReader(payload.Encode()))
req := httptest.NewRequest(http.MethodPost, "https://hub.example.com/", strings.NewReader(payload.Encode()))
req.Header.Set(common.HeaderContentType, common.MIMEApplicationFormCharsetUTF8)
w := httptest.NewRecorder()
delivery.NewHandler(delivery.NewHandlerParams{
Hub: hub,
Subscriptions: subscriptions,
Subscriptions: subscriptionucase.NewSubscriptionUseCase(subscriptions, topics),
Topics: topicucase.NewTopicUseCase(topics, srv.Client()),
Matcher: language.NewMatcher([]language.Tag{language.English}),
Name: "hub",
Name: "WebSub",
}).ServeHTTP(w, req)
resp := w.Result()
@ -104,8 +105,4 @@ func TestHandler_ServeHTTP_Unsubscribe(t *testing.T) {
if expect := http.StatusAccepted; resp.StatusCode != expect {
t.Errorf("%s %s = %d, want %d", req.Method, req.RequestURI, resp.StatusCode, expect)
}
if _, err := subscriptions.Get(context.Background(), in.SUID()); !errors.Is(err, subscription.ErrNotExist) {
t.Errorf("want %s, got %s", subscription.ErrNotExist, err)
}
}

View File

@ -8,9 +8,8 @@ import (
)
type UseCase interface {
Subscribe(ctx context.Context, subscription domain.Subscription) (bool, error)
Unsubscribe(ctx context.Context, subscription domain.Subscription) (bool, error)
Publish(ctx context.Context, t domain.Topic) error
Verify(ctx context.Context, subscription domain.Subscription, mode domain.Mode) (bool, error)
ListenAndServe(ctx context.Context) error
}
var (

View File

@ -3,21 +3,26 @@ package usecase
import (
"bytes"
"context"
"errors"
"crypto/hmac"
"encoding/hex"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"strconv"
"time"
"source.toby3d.me/toby3d/hub/internal/common"
"source.toby3d.me/toby3d/hub/internal/domain"
"source.toby3d.me/toby3d/hub/internal/hub"
"source.toby3d.me/toby3d/hub/internal/subscription"
"source.toby3d.me/toby3d/hub/internal/topic"
)
type hubUseCase struct {
subscriptions subscription.Repository
topics topic.Repository
client *http.Client
self *url.URL
}
@ -27,11 +32,12 @@ const (
lengthMax = 32
)
func NewHubUseCase(subscriptions subscription.Repository, client *http.Client, self *url.URL) hub.UseCase {
func NewHubUseCase(t topic.Repository, s subscription.Repository, c *http.Client, u *url.URL) hub.UseCase {
return &hubUseCase{
subscriptions: subscriptions,
client: client,
self: self,
client: c,
self: u,
topics: t,
subscriptions: s,
}
}
@ -41,15 +47,15 @@ func (ucase *hubUseCase) Verify(ctx context.Context, s domain.Subscription, mode
return false, fmt.Errorf("cannot generate hub.challenge: %w", err)
}
u := s.Callback.URL()
u, _ := url.Parse(s.Callback.String())
q := u.Query()
for _, w := range []domain.QueryAdder{mode, s.Topic, challenge} {
w.AddQuery(q)
}
mode.AddQuery(q)
q.Add(common.HubTopic, s.Topic.String())
challenge.AddQuery(q)
if mode == domain.ModeSubscribe {
s.LeaseSeconds.AddQuery(q)
q.Add(common.HubLeaseSeconds, strconv.FormatFloat(s.LeaseSeconds(), 'g', 0, 64))
}
u.RawQuery = q.Encode()
@ -77,118 +83,104 @@ func (ucase *hubUseCase) Verify(ctx context.Context, s domain.Subscription, mode
return false, fmt.Errorf("cannot verify subscriber response body: %w", err)
}
if !challenge.Equal(body) {
if !challenge.Equal(string(body)) {
return false, fmt.Errorf("%w: got '%s', want '%s'", hub.ErrChallenge, body, *challenge)
}
return true, nil
}
func (ucase *hubUseCase) Subscribe(ctx context.Context, s domain.Subscription) (bool, error) {
var err error
if _, err = ucase.Verify(ctx, s, domain.ModeSubscribe); err != nil {
return false, fmt.Errorf("cannot validate subscription request: %w", err)
}
func (ucase *hubUseCase) ListenAndServe(ctx context.Context) error {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
suid := s.SUID()
for ts := range ticker.C {
ts = ts.Round(time.Second)
if _, err = ucase.subscriptions.Get(ctx, suid); err != nil {
if !errors.Is(err, subscription.ErrNotExist) {
return false, fmt.Errorf("cannot check exists subscriptions: %w", err)
topics, err := ucase.topics.Fetch(ctx)
if err != nil {
return fmt.Errorf("cannot fetch topics: %w", err)
}
if err = ucase.subscriptions.Create(ctx, suid, s); err != nil {
return false, fmt.Errorf("cannot create a new subscription: %w", err)
for i := range topics {
subscriptions, err := ucase.subscriptions.Fetch(ctx, &topics[i])
if err != nil {
return fmt.Errorf("cannot fetch subscriptions: %w", err)
}
for j := range subscriptions {
if subscriptions[j].Expired(ts) {
if err = ucase.subscriptions.Delete(ctx, subscriptions[j].SUID()); err != nil {
return fmt.Errorf("cannot remove expired subcription: %w", err)
}
continue
}
if subscriptions[j].Synced(topics[i]) {
continue
}
go ucase.push(ctx, subscriptions[j], topics[i], ts)
}
}
return true, nil
}
if err = ucase.subscriptions.Update(ctx, suid, func(buf *domain.Subscription) (*domain.Subscription, error) {
buf.LeaseSeconds = s.LeaseSeconds
buf.Secret = s.Secret
return buf, nil
}); err != nil {
return false, fmt.Errorf("cannot update subscription: %w", err)
}
return false, nil
}
func (ucase *hubUseCase) Unsubscribe(ctx context.Context, s domain.Subscription) (bool, error) {
var err error
if _, err = ucase.Verify(ctx, s, domain.ModeUnsubscribe); err != nil {
return false, fmt.Errorf("cannot validate unsubscription request: %w", err)
}
if err = ucase.subscriptions.Delete(ctx, s.SUID()); err != nil {
return false, fmt.Errorf("cannot remove subscription: %w", err)
}
return true, nil
}
func (ucase *hubUseCase) Publish(ctx context.Context, t domain.Topic) error {
resp, err := ucase.client.Get(t.String())
if err != nil {
return fmt.Errorf("cannot fetch topic payload for publishing: %w", err)
}
push := domain.Push{ContentType: resp.Header.Get(common.HeaderContentType)}
canonicalTopic, err := domain.ParseTopic(resp.Request.URL.String())
if err != nil {
return fmt.Errorf("cannot parse canonical topic URL: %w", err)
}
push.Self = *canonicalTopic
if push.Content, err = io.ReadAll(resp.Body); err != nil {
return fmt.Errorf("cannot read topic body: %w", err)
}
subscriptions, err := ucase.subscriptions.Fetch(ctx, t)
if err != nil {
return fmt.Errorf("cannot fetch subscriptions for topic: %w", err)
}
for i := range subscriptions {
ucase.Push(ctx, push, subscriptions[i])
}
return nil
}
func (ucase *hubUseCase) Push(ctx context.Context, p domain.Push, s domain.Subscription) (bool, error) {
req, err := http.NewRequest(http.MethodPost, s.Callback.String(), bytes.NewReader(p.Content))
func (ucase *hubUseCase) push(ctx context.Context, s domain.Subscription, t domain.Topic, ts time.Time) (bool, error) {
req, err := http.NewRequest(http.MethodPost, s.Callback.String(), bytes.NewReader(t.Content))
if err != nil {
return false, fmt.Errorf("cannot build push request: %w", err)
return false, fmt.Errorf("cannot build request: %w", err)
}
req.Header.Set(common.HeaderContentType, p.ContentType)
req.Header.Set(common.HeaderLink, `<`+ucase.self.String()+`>; rel="hub", <`+p.Self.String()+`>; rel="self"`)
p.SetXHubSignatureHeader(req, domain.AlgorithmSHA512, s.Secret)
req.Header.Set(common.HeaderContentType, t.ContentType)
req.Header.Set(common.HeaderLink, `<`+ucase.self.String()+`>; rel="hub", <`+s.Topic.String()+`>; rel="self"`)
setXHubSignatureHeader(req, domain.AlgorithmSHA512, s.Secret, t.Content)
resp, err := ucase.client.Do(req)
if err != nil {
return false, fmt.Errorf("cannot push: %w", err)
}
// The subscriber's callback URL MAY return an HTTP 410 code to indicate that the subscription has been
// deleted, and the hub MAY terminate the subscription if it receives that code as a response.
suid := s.SUID()
// The subscriber's callback URL MAY return an HTTP 410 code to indicate
// that the subscription has been deleted, and the hub MAY terminate the
// subscription if it receives that code as a response.
if resp.StatusCode == http.StatusGone {
if err = ucase.subscriptions.Delete(ctx, s.SUID()); err != nil {
if err = ucase.subscriptions.Delete(ctx, suid); err != nil {
return false, fmt.Errorf("cannot remove deleted subscription: %w", err)
}
return true, nil
}
// The subscriber's callback URL MUST return an HTTP 2xx response code to indicate a success.
// The subscriber's callback URL MUST return an HTTP 2xx response code
// to indicate a success.
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return false, hub.ErrStatus
}
if err = ucase.subscriptions.Update(ctx, suid, func(tx *domain.Subscription) (*domain.Subscription, error) {
tx.SyncedAt = t.UpdatedAt
return tx, nil
}); err != nil {
return false, fmt.Errorf("cannot sync sybsciption status: %w", err)
}
return true, nil
}
func setXHubSignatureHeader(req *http.Request, alg domain.Algorithm, secret domain.Secret, body []byte) {
if !secret.IsSet() || alg == domain.AlgorithmUnd {
return
}
h := hmac.New(alg.Hash, []byte(secret.String()))
h.Write(body)
req.Header.Set(common.HeaderXHubSignature, alg.String()+"="+hex.EncodeToString(h.Sum(nil)))
}

View File

@ -0,0 +1,43 @@
package usecase_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"source.toby3d.me/toby3d/hub/internal/common"
"source.toby3d.me/toby3d/hub/internal/domain"
hubucase "source.toby3d.me/toby3d/hub/internal/hub/usecase"
subscriptionmemoryrepo "source.toby3d.me/toby3d/hub/internal/subscription/repository/memory"
topicmemoryrepo "source.toby3d.me/toby3d/hub/internal/topic/repository/memory"
)
func TestHubUseCase_Verify(t *testing.T) {
t.Parallel()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(common.HeaderContentType, common.MIMETextPlainCharsetUTF8)
fmt.Fprint(w, r.FormValue(common.HubChallenge))
}))
t.Cleanup(srv.Close)
subscriptions := subscriptionmemoryrepo.NewMemorySubscriptionRepository()
topics := topicmemoryrepo.NewMemoryTopicRepository()
subscription := domain.TestSubscription(t, srv.URL)
ok, err := hubucase.NewHubUseCase(topics, subscriptions, srv.Client(), &url.URL{
Scheme: "https",
Host: "hub.example.com",
Path: "/",
}).Verify(context.Background(), *subscription, domain.ModeSubscribe)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Errorf("want %t, got %t", true, ok)
}
}

View File

@ -68,6 +68,7 @@ func LogFmtWithConfig(config LogFmtConfig) Interceptor {
next(rw, r)
rw.error, _ = r.Context().Value("error").(error)
end := time.Now().UTC()
encoder.EncodeKeyvals(

View File

@ -13,7 +13,7 @@ type (
Repository interface {
Create(ctx context.Context, suid domain.SUID, subscription domain.Subscription) error
Get(ctx context.Context, suid domain.SUID) (*domain.Subscription, error)
Fetch(ctx context.Context, topic domain.Topic) ([]domain.Subscription, error)
Fetch(ctx context.Context, topic *domain.Topic) ([]domain.Subscription, error)
Update(ctx context.Context, suid domain.SUID, update UpdateFunc) error
Delete(ctx context.Context, suid domain.SUID) error
}

View File

@ -15,10 +15,15 @@ type memorySubscriptionRepository struct {
subscriptions map[domain.SUID]domain.Subscription
}
// Create implements subscription.Repository
func NewMemorySubscriptionRepository() subscription.Repository {
return &memorySubscriptionRepository{
mutex: new(sync.RWMutex),
subscriptions: make(map[domain.SUID]domain.Subscription),
}
}
func (repo *memorySubscriptionRepository) Create(ctx context.Context, suid domain.SUID, s domain.Subscription) error {
_, err := repo.Get(ctx, suid)
if err != nil {
if _, err := repo.Get(ctx, suid); err != nil {
if !errors.Is(err, subscription.ErrNotExist) {
return fmt.Errorf("cannot create subscription: %w", err)
}
@ -34,7 +39,6 @@ func (repo *memorySubscriptionRepository) Create(ctx context.Context, suid domai
return nil
}
// Delete implements subscription.Repository
func (repo *memorySubscriptionRepository) Delete(ctx context.Context, suid domain.SUID) error {
if _, err := repo.Get(ctx, suid); err != nil {
if !errors.Is(err, subscription.ErrNotExist) {
@ -52,7 +56,6 @@ func (repo *memorySubscriptionRepository) Delete(ctx context.Context, suid domai
return nil
}
// Get implements subscription.Repository
func (repo *memorySubscriptionRepository) Get(_ context.Context, suid domain.SUID) (*domain.Subscription, error) {
repo.mutex.RLock()
defer repo.mutex.RUnlock()
@ -64,14 +67,14 @@ func (repo *memorySubscriptionRepository) Get(_ context.Context, suid domain.SUI
return nil, subscription.ErrNotExist
}
func (repo *memorySubscriptionRepository) Fetch(ctx context.Context, t domain.Topic) ([]domain.Subscription, error) {
func (repo *memorySubscriptionRepository) Fetch(ctx context.Context, t *domain.Topic) ([]domain.Subscription, error) {
repo.mutex.RLock()
defer repo.mutex.RUnlock()
out := make([]domain.Subscription, 0)
for _, s := range repo.subscriptions {
if !s.Topic.Equal(t) {
if t != nil && t.Self.String() != s.Topic.String() {
continue
}
@ -100,10 +103,3 @@ func (repo *memorySubscriptionRepository) Update(ctx context.Context, suid domai
return nil
}
func NewMemorySubscriptionRepository() subscription.Repository {
return &memorySubscriptionRepository{
mutex: new(sync.RWMutex),
subscriptions: make(map[domain.SUID]domain.Subscription),
}
}

View File

@ -7,5 +7,6 @@ import (
)
type UseCase interface {
Fetch(ctx context.Context, topic domain.Topic) ([]domain.Subscription, error)
Subscribe(ctx context.Context, s domain.Subscription) (bool, error)
Unsubscribe(ctx context.Context, s domain.Subscription) (bool, error)
}

View File

@ -2,27 +2,94 @@ package usecase
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"time"
"source.toby3d.me/toby3d/hub/internal/common"
"source.toby3d.me/toby3d/hub/internal/domain"
"source.toby3d.me/toby3d/hub/internal/subscription"
"source.toby3d.me/toby3d/hub/internal/topic"
)
type subscriptionUseCase struct {
topics topic.Repository
subscriptions subscription.Repository
client *http.Client
}
func NewSubscriptionUseCase(subscriptions subscription.Repository) subscription.UseCase {
func NewSubscriptionUseCase(subs subscription.Repository, tops topic.Repository, c *http.Client) subscription.UseCase {
return &subscriptionUseCase{
subscriptions: subscriptions,
subscriptions: subs,
topics: tops,
client: c,
}
}
func (ucase *subscriptionUseCase) Fetch(ctx context.Context, topic domain.Topic) ([]domain.Subscription, error) {
out, err := ucase.subscriptions.Fetch(ctx, topic)
if err != nil {
return nil, fmt.Errorf("cannot fetch subscriptions for topic: %w", err)
func (ucase *subscriptionUseCase) Subscribe(ctx context.Context, s domain.Subscription) (bool, error) {
now := time.Now().UTC().Round(time.Second)
if _, err := ucase.topics.Get(context.Background(), s.Topic); err != nil {
if !errors.Is(err, topic.ErrNotExist) {
return false, fmt.Errorf("cannot check subscription topic: %w", err)
}
resp, err := ucase.client.Get(s.Topic.String())
if err != nil {
return false, fmt.Errorf("cannot fetch a new topic subscription content: %w", err)
}
content, err := io.ReadAll(resp.Body)
if err != nil {
return false, fmt.Errorf("cannot read a new topic subscription content: %w", err)
}
if err = ucase.topics.Create(ctx, s.Topic, domain.Topic{
CreatedAt: now,
UpdatedAt: now,
Self: s.Topic,
ContentType: resp.Header.Get(common.HeaderContentType),
Content: content,
}); err != nil {
return false, fmt.Errorf("cannot create topic for subsciption: %w", err)
}
}
return out, nil
if err := ucase.subscriptions.Create(ctx, s.SUID(), domain.Subscription{
CreatedAt: now,
UpdatedAt: now,
SyncedAt: now,
ExpiredAt: s.ExpiredAt,
Callback: s.Callback,
Topic: s.Topic,
Secret: s.Secret,
}); err != nil {
if !errors.Is(err, subscription.ErrExist) {
return false, fmt.Errorf("cannot create a new subscription: %w", err)
}
if err = ucase.subscriptions.Update(ctx, s.SUID(), func(tx *domain.Subscription) (*domain.Subscription,
error,
) {
tx.UpdatedAt = now
tx.ExpiredAt = now.Add(time.Duration(s.LeaseSeconds()) * time.Second)
tx.Secret = s.Secret
return tx, nil
}); err != nil {
return false, fmt.Errorf("cannot resubscribe existing subscription: %w", err)
}
}
return true, nil
}
func (ucase *subscriptionUseCase) Unsubscribe(ctx context.Context, s domain.Subscription) (bool, error) {
if err := ucase.subscriptions.Delete(ctx, s.SUID()); err != nil {
return false, fmt.Errorf("cannot unsubscribe: %w", err)
}
return true, nil
}

View File

@ -0,0 +1,73 @@
package usecase_test
import (
"context"
"testing"
"source.toby3d.me/toby3d/hub/internal/domain"
subscriptionmemoryrepo "source.toby3d.me/toby3d/hub/internal/subscription/repository/memory"
"source.toby3d.me/toby3d/hub/internal/subscription/usecase"
topicmemoryrepo "source.toby3d.me/toby3d/hub/internal/topic/repository/memory"
)
func TestSubscriptionUseCase_Subscribe(t *testing.T) {
t.Parallel()
subscription := domain.TestSubscription(t, "https://example.com/")
topics := topicmemoryrepo.NewMemoryTopicRepository()
subscriptions := subscriptionmemoryrepo.NewMemorySubscriptionRepository()
ucase := usecase.NewSubscriptionUseCase(subscriptions, topics)
ok, err := ucase.Subscribe(context.Background(), *subscription)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Errorf("want %t, got %t", true, ok)
}
if _, err := subscriptions.Get(context.Background(), subscription.SUID()); err != nil {
t.Fatal(err)
}
t.Run("resubscribe", func(t *testing.T) {
t.Parallel()
ok, err := ucase.Subscribe(context.Background(), *subscription)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Errorf("want %t, got %t", true, ok)
}
})
}
func TestSubscriptionUseCase_Unsubscribe(t *testing.T) {
t.Parallel()
subscription := domain.TestSubscription(t, "https://example.com/")
topics := topicmemoryrepo.NewMemoryTopicRepository()
subscriptions := subscriptionmemoryrepo.NewMemorySubscriptionRepository()
if err := subscriptions.Create(context.Background(), subscription.SUID(), *subscription); err != nil {
t.Fatal(err)
}
ok, err := usecase.NewSubscriptionUseCase(subscriptions, topics).
Unsubscribe(context.Background(), *subscription)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Errorf("want %t, got %t", true, ok)
}
if _, err := subscriptions.Get(context.Background(), subscription.SUID()); err == nil {
t.Error("want error, got nil")
}
}

View File

@ -0,0 +1,25 @@
package topic
import (
"context"
"errors"
"net/url"
"source.toby3d.me/toby3d/hub/internal/domain"
)
type (
UpdateFunc func(t *domain.Topic) (*domain.Topic, error)
Repository interface {
Create(ctx context.Context, u *url.URL, topic domain.Topic) error
Update(ctx context.Context, u *url.URL, update UpdateFunc) error
Fetch(ctx context.Context) ([]domain.Topic, error)
Get(ctx context.Context, u *url.URL) (*domain.Topic, error)
}
)
var (
ErrExist = errors.New("topic already exists")
ErrNotExist = errors.New("topic does not exist")
)

View File

@ -0,0 +1,85 @@
package memory
import (
"context"
"errors"
"fmt"
"net/url"
"sync"
"source.toby3d.me/toby3d/hub/internal/domain"
"source.toby3d.me/toby3d/hub/internal/topic"
)
type memoryTopicRepository struct {
mutex *sync.RWMutex
topics map[string]domain.Topic
}
func NewMemoryTopicRepository() topic.Repository {
return &memoryTopicRepository{
mutex: new(sync.RWMutex),
topics: make(map[string]domain.Topic),
}
}
func (repo *memoryTopicRepository) Update(ctx context.Context, u *url.URL, update topic.UpdateFunc) error {
tx, err := repo.Get(ctx, u)
if err != nil {
return fmt.Errorf("cannot find updating topic: %w", err)
}
repo.mutex.Lock()
defer repo.mutex.Unlock()
result, err := update(tx)
if err != nil {
return fmt.Errorf("cannot update topic: %w", err)
}
repo.topics[u.String()] = *result
return nil
}
func (repo *memoryTopicRepository) Create(ctx context.Context, u *url.URL, t domain.Topic) error {
_, err := repo.Get(ctx, u)
if err != nil && !errors.Is(err, topic.ErrNotExist) {
return fmt.Errorf("cannot get topic: %w", err)
}
if err == nil {
return topic.ErrExist
}
repo.mutex.Lock()
defer repo.mutex.Unlock()
repo.topics[u.String()] = t
return nil
}
func (repo *memoryTopicRepository) Get(ctx context.Context, u *url.URL) (*domain.Topic, error) {
repo.mutex.RLock()
defer repo.mutex.RUnlock()
if out, ok := repo.topics[u.String()]; ok {
return &out, nil
}
return nil, topic.ErrNotExist
}
func (repo *memoryTopicRepository) Fetch(_ context.Context) ([]domain.Topic, error) {
repo.mutex.RLock()
defer repo.mutex.RUnlock()
out := make([]domain.Topic, 0)
for _, t := range repo.topics {
out = append(out, t)
}
return out, nil
}

10
internal/topic/usecase.go Normal file
View File

@ -0,0 +1,10 @@
package topic
import (
"context"
"net/url"
)
type UseCase interface {
Publish(ctx context.Context, u *url.URL) (bool, error)
}

View File

@ -0,0 +1,66 @@
package usecase
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"source.toby3d.me/toby3d/hub/internal/common"
"source.toby3d.me/toby3d/hub/internal/domain"
"source.toby3d.me/toby3d/hub/internal/topic"
)
type topicUseCase struct {
client *http.Client
topics topic.Repository
}
func NewTopicUseCase(topics topic.Repository, client *http.Client) topic.UseCase {
return &topicUseCase{
topics: topics,
client: client,
}
}
func (ucase *topicUseCase) Publish(ctx context.Context, u *url.URL) (bool, error) {
now := time.Now().UTC().Round(time.Second)
resp, err := ucase.client.Get(u.String())
if err != nil {
return false, fmt.Errorf("cannot fetch publishing url: %w", err)
}
content, err := io.ReadAll(resp.Body)
if err != nil {
return false, fmt.Errorf("cannot read topic response body: %w", err)
}
if err := ucase.topics.Update(ctx, u, func(tx *domain.Topic) (*domain.Topic, error) {
tx.Self = resp.Request.URL
tx.UpdatedAt = now
tx.Content = content
tx.ContentType = resp.Header.Get(common.HeaderContentType)
return tx, nil
}); err != nil {
if !errors.Is(err, topic.ErrNotExist) {
return false, fmt.Errorf("cannot publish exists topic: %w", err)
}
if err = ucase.topics.Create(ctx, resp.Request.URL, domain.Topic{
CreatedAt: now,
UpdatedAt: now,
Self: resp.Request.URL,
ContentType: resp.Header.Get(common.HeaderContentType),
Content: content,
}); err != nil {
return false, fmt.Errorf("cannot publish a new topic: %w", err)
}
}
return true, nil
}

View File

@ -0,0 +1,43 @@
package usecase_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"source.toby3d.me/toby3d/hub/internal/common"
"source.toby3d.me/toby3d/hub/internal/domain"
topicmemoryrepo "source.toby3d.me/toby3d/hub/internal/topic/repository/memory"
"source.toby3d.me/toby3d/hub/internal/topic/usecase"
)
func TestTopicUseCase_Publish(t *testing.T) {
t.Parallel()
topic := domain.TestTopic(t)
topics := topicmemoryrepo.NewMemoryTopicRepository()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(common.HeaderContentType, topic.ContentType)
fmt.Fprint(w, topic.Content)
}))
t.Cleanup(srv.Close)
topic.Self, _ = url.Parse(srv.URL + "/")
ok, err := usecase.NewTopicUseCase(topics, srv.Client()).
Publish(context.Background(), topic.Self)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Errorf("want %t, got %t", true, ok)
}
if _, err := topics.Get(context.Background(), topic.Self); err != nil {
t.Fatal(err)
}
}

View File

@ -0,0 +1,37 @@
package urlutil_test
import (
"testing"
"source.toby3d.me/toby3d/hub/internal/urlutil"
)
func TestShiftPath(t *testing.T) {
t.Parallel()
for name, tc := range map[string]struct {
input string
expect [2]string
}{
"empty": {input: "", expect: [2]string{"", "/"}},
"root": {input: "/", expect: [2]string{"", "/"}},
"page": {input: "/foo", expect: [2]string{"foo", "/"}},
"folder": {input: "/foo/bar", expect: [2]string{"foo", "/bar"}},
} {
name, tc := name, tc
t.Run(name, func(t *testing.T) {
t.Parallel()
head, tail := urlutil.ShiftPath(tc.input)
if head != tc.expect[0] {
t.Errorf("want '%s', got '%s'", tc.expect[0], head)
}
if tail != tc.expect[1] {
t.Errorf("want '%s', got '%s'", tc.expect[1], tail)
}
})
}
}

29
main.go
View File

@ -5,6 +5,7 @@
package main
import (
"context"
"embed"
"io/fs"
"log"
@ -24,6 +25,8 @@ import (
"source.toby3d.me/toby3d/hub/internal/middleware"
subscriptionmemoryrepo "source.toby3d.me/toby3d/hub/internal/subscription/repository/memory"
subscriptionucase "source.toby3d.me/toby3d/hub/internal/subscription/usecase"
topicmemoryrepo "source.toby3d.me/toby3d/hub/internal/topic/repository/memory"
topicucase "source.toby3d.me/toby3d/hub/internal/topic/usecase"
"source.toby3d.me/toby3d/hub/internal/urlutil"
)
@ -50,6 +53,8 @@ func init() {
}
func main() {
ctx := context.Background()
config := new(domain.Config)
if err := env.Parse(config, env.Options{Prefix: "HUB_"}); err != nil {
logger.Fatalln(err)
@ -60,33 +65,41 @@ func main() {
logger.Fatalln(err)
}
client := &http.Client{Timeout: 1 * time.Second}
client := &http.Client{Timeout: 5 * time.Second}
matcher := language.NewMatcher(message.DefaultCatalog.Languages())
subscriptions := subscriptionmemoryrepo.NewMemorySubscriptionRepository()
hub := hubhttprelivery.NewHandler(hubhttprelivery.NewHandlerParams{
Hub: hubucase.NewHubUseCase(subscriptions, client, config.BaseURL),
Subscriptions: subscriptionucase.NewSubscriptionUseCase(subscriptions),
topics := topicmemoryrepo.NewMemoryTopicRepository()
topicService := topicucase.NewTopicUseCase(topics, client)
subscriptionService := subscriptionucase.NewSubscriptionUseCase(subscriptions, topics, client)
hubService := hubucase.NewHubUseCase(topics, subscriptions, client, config.BaseURL)
handler := hubhttprelivery.NewHandler(hubhttprelivery.NewHandlerParams{
Hub: hubService,
Subscriptions: subscriptionService,
Topics: topicService,
Matcher: matcher,
Name: config.Name,
})
server := &http.Server{
Addr: ":3000",
Addr: config.Bind,
Handler: http.HandlerFunc(middleware.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
head, _ := urlutil.ShiftPath(r.URL.Path)
switch head {
case "":
hub.ServeHTTP(w, r)
handler.ServeHTTP(w, r)
case "static":
http.FileServer(http.FS(static)).ServeHTTP(w, r)
}
}).Intercept(middleware.LogFmt())),
ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
ErrorLog: logger,
}
go hubService.ListenAndServe(ctx)
if err = server.ListenAndServe(); err != nil {
logger.Fatalln(err)
}

View File

@ -1,24 +1,16 @@
{
"lang": "en",
"dir": "ltr",
"name": "WebSub",
"description": "A dead simple WebSub hub",
"short_name": "WebSub",
"icons": [{
"purpose": "maskable",
"sizes": "192x192",
"src": "icon-192x192.png",
"type": "image/png"
},{
"purpose": "maskable",
"sizes": "512x512",
"src": "icon-512x512.png",
"type": "image/png"
}],
"scope": "/",
"start_url": "/",
"display": "fullscreen",
"orientation": "landscape",
"theme_color": "#ff6fcf",
"background_color": "#ff6fcf"
}
"icons": [
{
"purpose": "maskable",
"sizes": "192x192",
"src": "icon-192x192.png",
"type": "image/png"
},
{
"purpose": "maskable",
"sizes": "512x512",
"src": "icon-512x512.png",
"type": "image/png"
}
]
}

View File

@ -38,6 +38,9 @@ func NewBaseOf(lang language.Tag, name string) *BaseOf {
{% func (p *BaseOf) head() %}
{% comment %}https://evilmartians.com/chronicles/how-to-favicon-in-2021-six-files-that-fit-most-needs{% endcomment %}
<link rel="manifest"
href="/static/manifest.webmanifest">
<link rel="icon"
href="/static/favicon.ico"
sizes="any">
@ -48,9 +51,6 @@ func NewBaseOf(lang language.Tag, name string) *BaseOf {
<link rel="apple-touch-icon"
href="/static/apple-touch-icon.png">
<link rel="manifest"
href="/static/manifest.webmanifest">
{% endfunc %}
{% func (p *BaseOf) body() %}{% endfunc %}