Initial
This commit is contained in:
211
internal/httpapi/handlers.go
Normal file
211
internal/httpapi/handlers.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"netisjwt/internal/jws"
|
||||
"netisjwt/internal/statuslist"
|
||||
"netisjwt/internal/storage"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
store storage.Store
|
||||
privatePEM []byte
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewObravnalnik ustvari HTTP obravnalnik za status API.
|
||||
func NewObravnalnik(store storage.Store, privatePEM []byte) *Handler {
|
||||
return &Handler{
|
||||
store: store,
|
||||
privatePEM: privatePEM,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// obravnavajStatusRoot obdela /api/status za GET in POST.
|
||||
func (h *Handler) obravnavajStatusRoot(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
ids, err := h.store.SeznamIDjev(r.Context())
|
||||
if err != nil {
|
||||
zapisiNapako(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
zapisiJSON(w, http.StatusOK, map[string]any{"statusIds": ids})
|
||||
case http.MethodPost:
|
||||
statusID, err := newStatusID()
|
||||
if err != nil {
|
||||
zapisiNapako(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if err := h.store.Ustvari(r.Context(), statusID, statuslist.New()); err != nil {
|
||||
zapisiNapako(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
zapisiJSON(w, http.StatusCreated, map[string]any{"statusId": statusID})
|
||||
default:
|
||||
zapisiMethodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
// obravnavajStatusPoPoti razcleni path parametre statusId in index.
|
||||
func (h *Handler) obravnavajStatusPoPoti(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/status/")
|
||||
path = strings.Trim(path, "/")
|
||||
if path == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) == 1 {
|
||||
if r.Method != http.MethodPost {
|
||||
zapisiMethodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
h.obravnavajDodajStatus(w, r, parts[0])
|
||||
return
|
||||
}
|
||||
if len(parts) == 2 {
|
||||
index, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
zapisiNapako(w, http.StatusBadRequest, errors.New("index must be integer"))
|
||||
return
|
||||
}
|
||||
h.obravnavajIndeksiranStatus(w, r, parts[0], index)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// obravnavajDodajStatus doda novo stanje v status listo.
|
||||
func (h *Handler) obravnavajDodajStatus(w http.ResponseWriter, r *http.Request, statusID string) {
|
||||
list, err := h.store.Dobi(r.Context(), statusID)
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
zapisiNapako(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
zapisiNapako(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
index := list.Dodaj(false)
|
||||
if err := h.store.Posodobi(r.Context(), statusID, list); err != nil {
|
||||
zapisiNapako(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
zapisiJSON(w, http.StatusCreated, map[string]any{"index": index})
|
||||
}
|
||||
|
||||
// obravnavajIndeksiranStatus vodi GET/PUT/DELETE za en status index.
|
||||
func (h *Handler) obravnavajIndeksiranStatus(w http.ResponseWriter, r *http.Request, statusID string, index int) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.obravnavajDobiZeton(w, r, statusID, index)
|
||||
case http.MethodPut:
|
||||
h.obravnavajNastavi(w, r, statusID, index, true)
|
||||
case http.MethodDelete:
|
||||
h.obravnavajNastavi(w, r, statusID, index, false)
|
||||
default:
|
||||
zapisiMethodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
// obravnavajNastavi nastavi vrednost enega status bita.
|
||||
func (h *Handler) obravnavajNastavi(w http.ResponseWriter, r *http.Request, statusID string, index int, value bool) {
|
||||
list, err := h.store.Dobi(r.Context(), statusID)
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
zapisiNapako(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
zapisiNapako(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if err := list.Nastavi(index, value); err != nil {
|
||||
zapisiNapako(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := h.store.Posodobi(r.Context(), statusID, list); err != nil {
|
||||
zapisiNapako(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// obravnavajDobiZeton vrne podpisan JWS token za podan index.
|
||||
func (h *Handler) obravnavajDobiZeton(w http.ResponseWriter, r *http.Request, statusID string, index int) {
|
||||
list, err := h.store.Dobi(r.Context(), statusID)
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
zapisiNapako(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
zapisiNapako(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if _, err := list.Dobi(index); err != nil {
|
||||
zapisiNapako(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
issuer := izdajaIzdajatelja(r, statusID)
|
||||
claims, err := jws.SestaviTrditve(issuer, list, index, h.now())
|
||||
if err != nil {
|
||||
zapisiNapako(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
token, err := jws.UstvariZetonIzPEM(h.privatePEM, claims)
|
||||
if err != nil {
|
||||
zapisiNapako(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(token))
|
||||
}
|
||||
|
||||
// izdajaIzdajatelja pripravi iss vrednost za JWT trditve.
|
||||
func izdajaIzdajatelja(r *http.Request, statusID string) string {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + r.Host + "/api/status/" + statusID
|
||||
}
|
||||
|
||||
// newStatusID ustvari kratek URL-safe identifikator seznama.
|
||||
func newStatusID() (string, error) {
|
||||
raw := make([]byte, 6)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
// zapisiMethodNotAllowed vrne standarden odgovor 405.
|
||||
func zapisiMethodNotAllowed(w http.ResponseWriter) {
|
||||
zapisiNapako(w, http.StatusMethodNotAllowed, errors.New("method not allowed"))
|
||||
}
|
||||
|
||||
// zapisiNapako serializira napako v JSON odziv.
|
||||
func zapisiNapako(w http.ResponseWriter, code int, err error) {
|
||||
zapisiJSON(w, code, map[string]any{"error": err.Error()})
|
||||
}
|
||||
|
||||
// zapisiJSON zapise poljuben objekt kot JSON odgovor.
|
||||
func zapisiJSON(w http.ResponseWriter, code int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
125
internal/httpapi/handlers_test.go
Normal file
125
internal/httpapi/handlers_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
appcrypto "netisjwt/internal/crypto"
|
||||
"netisjwt/internal/statuslist"
|
||||
"netisjwt/internal/storage"
|
||||
)
|
||||
|
||||
type mockStore struct {
|
||||
data map[string]*statuslist.StatusList
|
||||
}
|
||||
|
||||
func newMockStore() *mockStore {
|
||||
return &mockStore{data: make(map[string]*statuslist.StatusList)}
|
||||
}
|
||||
|
||||
func (m *mockStore) Ustvari(_ context.Context, statusID string, list *statuslist.StatusList) error {
|
||||
m.data[statusID] = mustCloneList(list)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockStore) SeznamIDjev(_ context.Context) ([]string, error) {
|
||||
out := make([]string, 0, len(m.data))
|
||||
for id := range m.data {
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *mockStore) Dobi(_ context.Context, statusID string) (*statuslist.StatusList, error) {
|
||||
list, ok := m.data[statusID]
|
||||
if !ok {
|
||||
return nil, storage.ErrNotFound
|
||||
}
|
||||
return mustCloneList(list), nil
|
||||
}
|
||||
|
||||
func (m *mockStore) Posodobi(_ context.Context, statusID string, list *statuslist.StatusList) error {
|
||||
if _, ok := m.data[statusID]; !ok {
|
||||
return storage.ErrNotFound
|
||||
}
|
||||
m.data[statusID] = mustCloneList(list)
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustCloneList(list *statuslist.StatusList) *statuslist.StatusList {
|
||||
clone, err := statuslist.IzSurovihPodatkov(list.Bajti(), list.DolzinaBitov())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func TestStatusFlow(t *testing.T) {
|
||||
privatePEM, err := appcrypto.UstvariZasebniKljucECDSAP256PEM()
|
||||
if err != nil {
|
||||
t.Fatalf("key gen: %v", err)
|
||||
}
|
||||
|
||||
store := newMockStore()
|
||||
handler := NewObravnalnik(store, privatePEM)
|
||||
router := NewUsmerjevalnik(handler)
|
||||
|
||||
statusID := createListAndReturnID(t, router)
|
||||
createIndexAndSetTrue(t, router, statusID)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/status/"+statusID+"/0", nil)
|
||||
req.Host = "localhost:8000"
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
token := strings.TrimSpace(rec.Body.String())
|
||||
if strings.Count(token, ".") != 2 {
|
||||
t.Fatalf("expected compact JWS token")
|
||||
}
|
||||
}
|
||||
|
||||
func createListAndReturnID(t *testing.T, router http.Handler) string {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
StatusID string `json:"statusId"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("invalid json: %v", err)
|
||||
}
|
||||
if response.StatusID == "" {
|
||||
t.Fatalf("expected non-empty statusId")
|
||||
}
|
||||
return response.StatusID
|
||||
}
|
||||
|
||||
func createIndexAndSetTrue(t *testing.T, router http.Handler, statusID string) {
|
||||
t.Helper()
|
||||
|
||||
addReq := httptest.NewRequest(http.MethodPost, "/api/status/"+statusID, nil)
|
||||
addRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(addRec, addReq)
|
||||
if addRec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d", addRec.Code)
|
||||
}
|
||||
|
||||
setReq := httptest.NewRequest(http.MethodPut, "/api/status/"+statusID+"/0", nil)
|
||||
setRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(setRec, setReq)
|
||||
if setRec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", setRec.Code)
|
||||
}
|
||||
}
|
||||
11
internal/httpapi/router.go
Normal file
11
internal/httpapi/router.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package httpapi
|
||||
|
||||
import "net/http"
|
||||
|
||||
// NewUsmerjevalnik pripravi vse API poti za status endpointe.
|
||||
func NewUsmerjevalnik(handler *Handler) *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/status", handler.obravnavajStatusRoot)
|
||||
mux.HandleFunc("/api/status/", handler.obravnavajStatusPoPoti)
|
||||
return mux
|
||||
}
|
||||
Reference in New Issue
Block a user