From 7173dbc2fe6f0d8490b6f9130e51fdbab8ab7deb Mon Sep 17 00:00:00 2001 From: Paramtamtam <7326800+tarampampam@users.noreply.github.com> Date: Sun, 10 Nov 2024 20:35:42 +0400 Subject: [PATCH 1/8] =?UTF-8?q?wip:=20=F0=9F=94=95=20temporary=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- api/openapi.yml | 49 +- internal/cli/start/command.go | 6 +- .../http/handlers/request_delete/handler.go | 39 +- .../handlers/requests_delete_all/handler.go | 16 +- .../handlers/requests_subscribe/handler.go | 54 ++- .../handlers/session_check_exists/handler.go | 55 +++ .../http/middleware/webhook/middleware.go | 19 +- internal/http/openapi.go | 64 ++- internal/http/server.go | 8 +- internal/http/server_test.go | 2 +- internal/pubsub/pubsub.go | 15 +- web/package-lock.json | 31 +- web/package.json | 3 + web/src/api/client.ts | 76 ++- web/src/api/index.ts | 1 + web/src/db/database.ts | 196 ++++++++ web/src/db/errors.ts | 18 + web/src/db/index.ts | 2 + web/src/db/tables/index.ts | 2 + web/src/db/tables/requests.ts | 17 + web/src/db/tables/sessions.ts | 13 + web/src/main.tsx | 18 +- web/src/screens/components/header.tsx | 2 +- web/src/screens/home/screen.tsx | 3 +- web/src/screens/layout.tsx | 2 +- web/src/screens/session/screen.tsx | 133 +++--- web/src/shared/{ => hooks}/use-storage.ts | 0 web/src/shared/index.ts | 14 +- .../browser-notifications.tsx} | 0 web/src/shared/providers/data.tsx | 450 ++++++++++++++++++ .../sessions.tsx} | 2 +- .../ui-settings.tsx} | 2 +- web/src/shared/use-visibility-change.ts | 20 - .../shared/{base64.ts => utils/encoding.ts} | 0 web/src/shared/{ => utils}/url.ts | 0 web/src/theme/color.ts | 10 +- 37 files changed, 1167 insertions(+), 177 deletions(-) create mode 100644 internal/http/handlers/session_check_exists/handler.go create mode 100644 web/src/db/database.ts create mode 100644 web/src/db/errors.ts create mode 100644 web/src/db/index.ts create mode 100644 web/src/db/tables/index.ts create mode 100644 web/src/db/tables/requests.ts create mode 100644 web/src/db/tables/sessions.ts rename web/src/shared/{ => hooks}/use-storage.ts (100%) rename web/src/shared/{browser-notifications-provider.tsx => providers/browser-notifications.tsx} (100%) create mode 100644 web/src/shared/providers/data.tsx rename web/src/shared/{sessions-provider.tsx => providers/sessions.tsx} (97%) rename web/src/shared/{ui-settings-provider.tsx => providers/ui-settings.tsx} (95%) delete mode 100644 web/src/shared/use-visibility-change.ts rename web/src/shared/{base64.ts => utils/encoding.ts} (100%) rename web/src/shared/{ => utils}/url.ts (100%) diff --git a/README.md b/README.md index 64ef03dc..33ed37ef 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ For custom configuration options, refer to the CLI help below or execute the app [link_docker_hub]:https://hub.docker.com/r/tarampampam/webhook-tester/ - + ## CLI interface webhook tester. diff --git a/api/openapi.yml b/api/openapi.yml index 6014c17f..724c1d2c 100644 --- a/api/openapi.yml +++ b/api/openapi.yml @@ -32,6 +32,16 @@ paths: '400': {$ref: '#/components/responses/ErrorResponse'} # Bad request '5XX': {$ref: '#/components/responses/ErrorResponse'} # Server error + /api/session/check/exists: + post: + summary: Batch check if sessions exist by UUID + tags: [api] + operationId: apiSessionCheckExists + requestBody: {$ref: '#/components/requestBodies/CheckSessionExistsRequest'} + responses: + '200': {$ref: '#/components/responses/CheckSessionExistsResponse'} + '5XX': {$ref: '#/components/responses/ErrorResponse'} # Server error + /api/session/{session_uuid}: get: summary: Get session options by UUID @@ -96,7 +106,7 @@ paths: description: WebSocket connection established content: application/json: - schema: {$ref: '#/components/schemas/CapturedRequestShort'} + schema: {$ref: '#/components/schemas/RequestEvent'} '400': {$ref: '#/components/responses/ErrorResponse'} # Bad request '5XX': {$ref: '#/components/responses/ErrorResponse'} # Server error @@ -272,9 +282,19 @@ components: required: [uuid, client_address, method, request_payload_base64, headers, url, captured_at_unix_milli] additionalProperties: false - CapturedRequestShort: + RequestEvent: + type: object + properties: + action: + type: string + enum: [create, delete, clear] + example: create + request: {$ref: '#/components/schemas/RequestEventRequest'} + required: [action] + additionalProperties: false + + RequestEventRequest: type: object - description: The same as CapturedRequest, but without the request payload properties: uuid: {$ref: '#/components/schemas/UUID'} client_address: {type: string, example: '214.184.32.7', description: 'May be IPv6 like 2a0e:4005:1002:ffff:185:40:4:132'} @@ -352,6 +372,16 @@ components: application/json: schema: {$ref: '#/components/schemas/SessionResponseOptions'} + CheckSessionExistsRequest: + description: Check if a session exists by UUID + content: + application/json: + schema: + type: array + items: {$ref: '#/components/schemas/UUID'} + minItems: 1 + maxItems: 100 + responses: # ---------------------------------------------- RESPONSES ----------------------------------------------- VersionResponse: description: Information about the version @@ -382,6 +412,19 @@ components: required: [uuid, response, created_at_unix_milli] additionalProperties: false + CheckSessionExistsResponse: + description: A hashmap of session UUIDs and their existence + content: + application/json: + schema: + type: object + additionalProperties: + type: boolean + example: true + example: + 9b6bbab9-c197-4dd3-bc3f-3cb6253820c7: true + 9b6bbab9-c197-4dd3-bc3f-3cb6253820c8: false + CapturedRequestsListResponse: description: List of captured requests, sorted from newest to oldest content: diff --git a/internal/cli/start/command.go b/internal/cli/start/command.go index 48f6f654..77e6bfe6 100644 --- a/internal/cli/start/command.go +++ b/internal/cli/start/command.go @@ -380,14 +380,14 @@ func (cmd *command) Run(parentCtx context.Context, log *zap.Logger) error { //no return fmt.Errorf("unknown storage driver [%s]", cmd.options.storage.driver) } - var pubSub pubsub.PubSub[pubsub.CapturedRequest] + var pubSub pubsub.PubSub[pubsub.RequestEvent] // create the Pub/Sub switch cmd.options.pubSub.driver { case pubSubDriverMemory: - pubSub = pubsub.NewInMemory[pubsub.CapturedRequest]() + pubSub = pubsub.NewInMemory[pubsub.RequestEvent]() case pubSubDriverRedis: - pubSub = pubsub.NewRedis[pubsub.CapturedRequest](rdc, encoding.JSON{}) + pubSub = pubsub.NewRedis[pubsub.RequestEvent](rdc, encoding.JSON{}) default: return fmt.Errorf("unknown Pub/Sub driver [%s]", cmd.options.pubSub.driver) } diff --git a/internal/http/handlers/request_delete/handler.go b/internal/http/handlers/request_delete/handler.go index ba2a78b2..80efa077 100644 --- a/internal/http/handlers/request_delete/handler.go +++ b/internal/http/handlers/request_delete/handler.go @@ -4,6 +4,7 @@ import ( "context" "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" + "gh.tarampamp.am/webhook-tester/v2/internal/pubsub" "gh.tarampamp.am/webhook-tester/v2/internal/storage" ) @@ -11,15 +12,49 @@ type ( sID = openapi.SessionUUIDInPath rID = openapi.RequestUUIDInPath - Handler struct{ db storage.Storage } + Handler struct { + appCtx context.Context + db storage.Storage + pub pubsub.Publisher[pubsub.RequestEvent] + } ) -func New(db storage.Storage) *Handler { return &Handler{db: db} } +func New(appCtx context.Context, db storage.Storage, pub pubsub.Publisher[pubsub.RequestEvent]) *Handler { + return &Handler{appCtx: appCtx, db: db, pub: pub} +} func (h *Handler) Handle(ctx context.Context, sID sID, rID rID) (*openapi.SuccessfulOperationResponse, error) { + // get the request from the storage to notify the subscribers + req, getErr := h.db.GetRequest(ctx, sID.String(), rID.String()) + if getErr != nil { + return nil, getErr + } + + // delete it if err := h.db.DeleteRequest(ctx, sID.String(), rID.String()); err != nil { return nil, err } + // convert headers to the pubsub format + var headers = make([]pubsub.HttpHeader, len(req.Headers)) + for i, rh := range req.Headers { + headers[i] = pubsub.HttpHeader{Name: rh.Name, Value: rh.Value} + } + + // notify the subscribers + if err := h.pub.Publish(h.appCtx, sID.String(), pubsub.RequestEvent{ //nolint:contextcheck + Action: pubsub.RequestActionDelete, + Request: &pubsub.Request{ + ID: rID.String(), + ClientAddr: req.ClientAddr, + Method: req.Method, + Headers: headers, + URL: req.URL, + CreatedAtUnixMilli: req.CreatedAtUnixMilli, + }, + }); err != nil { + return nil, err + } + return &openapi.SuccessfulOperationResponse{Success: true}, nil } diff --git a/internal/http/handlers/requests_delete_all/handler.go b/internal/http/handlers/requests_delete_all/handler.go index 13338008..52e92bb7 100644 --- a/internal/http/handlers/requests_delete_all/handler.go +++ b/internal/http/handlers/requests_delete_all/handler.go @@ -4,21 +4,33 @@ import ( "context" "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" + "gh.tarampamp.am/webhook-tester/v2/internal/pubsub" "gh.tarampamp.am/webhook-tester/v2/internal/storage" ) type ( sID = openapi.SessionUUIDInPath - Handler struct{ db storage.Storage } + Handler struct { + appCtx context.Context + db storage.Storage + pub pubsub.Publisher[pubsub.RequestEvent] + } ) -func New(db storage.Storage) *Handler { return &Handler{db: db} } +func New(appCtx context.Context, db storage.Storage, pub pubsub.Publisher[pubsub.RequestEvent]) *Handler { + return &Handler{appCtx: appCtx, db: db, pub: pub} +} func (h *Handler) Handle(ctx context.Context, sID sID) (*openapi.SuccessfulOperationResponse, error) { if err := h.db.DeleteAllRequests(ctx, sID.String()); err != nil { return nil, err } + // notify the subscribers + if err := h.pub.Publish(h.appCtx, sID.String(), pubsub.RequestEvent{Action: pubsub.RequestActionClear}); err != nil { //nolint:contextcheck,lll + return nil, err + } + return &openapi.SuccessfulOperationResponse{Success: true}, nil } diff --git a/internal/http/handlers/requests_subscribe/handler.go b/internal/http/handlers/requests_subscribe/handler.go index 12e47744..5d8d26b9 100644 --- a/internal/http/handlers/requests_subscribe/handler.go +++ b/internal/http/handlers/requests_subscribe/handler.go @@ -21,12 +21,12 @@ type ( Handler struct { db storage.Storage - sub pubsub.Subscriber[pubsub.CapturedRequest] + sub pubsub.Subscriber[pubsub.RequestEvent] upgrader websocket.Upgrader } ) -func New(db storage.Storage, sub pubsub.Subscriber[pubsub.CapturedRequest]) *Handler { +func New(db storage.Storage, sub pubsub.Subscriber[pubsub.RequestEvent]) *Handler { return &Handler{db: db, sub: sub} } @@ -93,7 +93,7 @@ func (*Handler) reader(ctx context.Context, ws *websocket.Conn) error { // will block until the context is canceled, the client closes the connection, or an error during the writing occurs. // // This function sends the captured requests to the client and pings the client periodically. -func (h *Handler) writer(ctx context.Context, ws *websocket.Conn, sub <-chan pubsub.CapturedRequest) error { +func (h *Handler) writer(ctx context.Context, ws *websocket.Conn, sub <-chan pubsub.RequestEvent) error { //nolint:funlen const pingInterval, pingDeadline = 10 * time.Second, 5 * time.Second // create a ticker for the ping messages @@ -110,25 +110,45 @@ func (h *Handler) writer(ctx context.Context, ws *websocket.Conn, sub <-chan pub return nil // this should never happen, but just in case } - rID, pErr := uuid.Parse(r.ID) - if pErr != nil { - continue + var ( + action openapi.RequestEventAction + request *openapi.RequestEventRequest + ) + + switch r.Action { + case pubsub.RequestActionCreate: + action = openapi.RequestEventActionCreate + case pubsub.RequestActionDelete: + action = openapi.RequestEventActionDelete + case pubsub.RequestActionClear: + action = openapi.RequestEventActionClear + default: + continue // skip the unknown action } - var rHeaders = make([]openapi.HttpHeader, len(r.Headers)) - for i, header := range r.Headers { - rHeaders[i].Name, rHeaders[i].Value = header.Name, header.Value + if r.Request != nil { + rID, pErr := uuid.Parse(r.Request.ID) + if pErr != nil { + continue + } + + var rHeaders = make([]openapi.HttpHeader, len(r.Request.Headers)) + for i, header := range r.Request.Headers { + rHeaders[i].Name, rHeaders[i].Value = header.Name, header.Value + } + + request = &openapi.RequestEventRequest{ + Uuid: rID, + CapturedAtUnixMilli: r.Request.CreatedAtUnixMilli, + ClientAddress: r.Request.ClientAddr, + Headers: rHeaders, + Method: strings.ToUpper(r.Request.Method), + Url: r.Request.URL, + } } // write the response to the client - if err := ws.WriteJSON(openapi.CapturedRequest{ - CapturedAtUnixMilli: r.CreatedAtUnixMilli, - ClientAddress: r.ClientAddr, - Headers: rHeaders, - Method: strings.ToUpper(r.Method), - Url: r.URL, - Uuid: rID, - }); err != nil { + if err := ws.WriteJSON(openapi.RequestEvent{Action: action, Request: request}); err != nil { return fmt.Errorf("failed to write the message: %w", err) } diff --git a/internal/http/handlers/session_check_exists/handler.go b/internal/http/handlers/session_check_exists/handler.go new file mode 100644 index 00000000..f3619df8 --- /dev/null +++ b/internal/http/handlers/session_check_exists/handler.go @@ -0,0 +1,55 @@ +package session_check_exists + +import ( + "context" + "errors" + "sync" + + "golang.org/x/sync/errgroup" + + "gh.tarampamp.am/webhook-tester/v2/internal/http/openapi" + "gh.tarampamp.am/webhook-tester/v2/internal/storage" +) + +type Handler struct{ db storage.Storage } + +func New(db storage.Storage) *Handler { return &Handler{db: db} } + +func (h *Handler) Handle(ctx context.Context, ids []openapi.UUID) (*openapi.CheckSessionExistsResponse, error) { + var ( + eg, egCtx = errgroup.WithContext(ctx) + + mu sync.Mutex + res = make(openapi.CheckSessionExistsResponse, len(ids)) // map[sID]bool + ) + + for _, id := range ids { + eg.Go(func(sID string) func() error { + return func() error { + if _, err := h.db.GetSession(egCtx, sID); err != nil { + if errors.Is(err, storage.ErrNotFound) { + mu.Lock() + res[sID] = false + mu.Unlock() + + return nil + } + + return err + } + + mu.Lock() + res[sID] = true + mu.Unlock() + + return nil + } + }(id.String())) + } + + if err := eg.Wait(); err != nil { + return nil, err + } + + return &res, nil +} diff --git a/internal/http/middleware/webhook/middleware.go b/internal/http/middleware/webhook/middleware.go index 9f98bfd7..4fa979e1 100644 --- a/internal/http/middleware/webhook/middleware.go +++ b/internal/http/middleware/webhook/middleware.go @@ -24,7 +24,7 @@ func New( //nolint:funlen,gocognit,gocyclo appCtx context.Context, log *zap.Logger, db storage.Storage, - pub pubsub.Publisher[pubsub.CapturedRequest], + pub pubsub.Publisher[pubsub.RequestEvent], cfg *config.AppSettings, ) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { @@ -146,13 +146,16 @@ func New( //nolint:funlen,gocognit,gocyclo headers[i] = pubsub.HttpHeader{Name: h.Name, Value: h.Value} } - if err := pub.Publish(appCtx, sID, pubsub.CapturedRequest{ - ID: rID, - ClientAddr: captured.ClientAddr, - Method: captured.Method, - Headers: headers, - URL: captured.URL, - CreatedAtUnixMilli: captured.CreatedAtUnixMilli, + if err := pub.Publish(appCtx, sID, pubsub.RequestEvent{ + Action: pubsub.RequestActionCreate, + Request: &pubsub.Request{ + ID: rID, + ClientAddr: captured.ClientAddr, + Method: captured.Method, + Headers: headers, + URL: captured.URL, + CreatedAtUnixMilli: captured.CreatedAtUnixMilli, + }, }); err != nil { log.Error("failed to publish a captured request", zap.Error(err)) } diff --git a/internal/http/openapi.go b/internal/http/openapi.go index 16272aa9..8ea0594b 100644 --- a/internal/http/openapi.go +++ b/internal/http/openapi.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "strings" @@ -17,6 +18,7 @@ import ( "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/requests_delete_all" "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/requests_list" "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/requests_subscribe" + "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/session_check_exists" "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/session_create" "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/session_delete" "gh.tarampamp.am/webhook-tester/v2/internal/http/handlers/session_get" @@ -39,43 +41,46 @@ type OpenAPI struct { log *zap.Logger handlers struct { - settingsGet func() openapi.SettingsResponse - sessionCreate func(context.Context, openapi.CreateSessionRequest) (*openapi.SessionOptionsResponse, error) - sessionGet func(context.Context, sID) (*openapi.SessionOptionsResponse, error) - sessionDelete func(context.Context, sID) (*openapi.SuccessfulOperationResponse, error) - requestsList func(context.Context, sID) (*openapi.CapturedRequestsListResponse, error) - requestsDelete func(context.Context, sID) (*openapi.SuccessfulOperationResponse, error) - requestsSubscribe func(context.Context, http.ResponseWriter, *http.Request, sID) error - requestGet func(context.Context, sID, rID) (*openapi.CapturedRequestsResponse, error) - requestDelete func(context.Context, sID, rID) (*openapi.SuccessfulOperationResponse, error) - appVersion func() openapi.VersionResponse - appVersionLatest func(context.Context, http.ResponseWriter) (*openapi.VersionResponse, error) - readinessProbe func(context.Context, http.ResponseWriter, string) - livenessProbe func(http.ResponseWriter, string) + settingsGet func() openapi.SettingsResponse + sessionCreate func(context.Context, openapi.CreateSessionRequest) (*openapi.SessionOptionsResponse, error) + sessionCheckExists func(ctx context.Context, ids []openapi.UUID) (*openapi.CheckSessionExistsResponse, error) + sessionGet func(context.Context, sID) (*openapi.SessionOptionsResponse, error) + sessionDelete func(context.Context, sID) (*openapi.SuccessfulOperationResponse, error) + requestsList func(context.Context, sID) (*openapi.CapturedRequestsListResponse, error) + requestsDelete func(context.Context, sID) (*openapi.SuccessfulOperationResponse, error) + requestsSubscribe func(context.Context, http.ResponseWriter, *http.Request, sID) error + requestGet func(context.Context, sID, rID) (*openapi.CapturedRequestsResponse, error) + requestDelete func(context.Context, sID, rID) (*openapi.SuccessfulOperationResponse, error) + appVersion func() openapi.VersionResponse + appVersionLatest func(context.Context, http.ResponseWriter) (*openapi.VersionResponse, error) + readinessProbe func(context.Context, http.ResponseWriter, string) + livenessProbe func(http.ResponseWriter, string) } } var _ openapi.ServerInterface = (*OpenAPI)(nil) // verify interface implementation func NewOpenAPI( + appCtx context.Context, log *zap.Logger, rdyChecker func(context.Context) error, lastAppVer func(context.Context) (string, error), cfg *config.AppSettings, db storage.Storage, - pubSub pubsub.PubSub[pubsub.CapturedRequest], + pubSub pubsub.PubSub[pubsub.RequestEvent], ) *OpenAPI { var si = &OpenAPI{log: log} si.handlers.settingsGet = settings_get.New(cfg).Handle si.handlers.sessionCreate = session_create.New(db).Handle + si.handlers.sessionCheckExists = session_check_exists.New(db).Handle si.handlers.sessionGet = session_get.New(db).Handle si.handlers.sessionDelete = session_delete.New(db).Handle si.handlers.requestsList = requests_list.New(db).Handle - si.handlers.requestsDelete = requests_delete_all.New(db).Handle + si.handlers.requestsDelete = requests_delete_all.New(appCtx, db, pubSub).Handle si.handlers.requestsSubscribe = requests_subscribe.New(db, pubSub).Handle si.handlers.requestGet = request_get.New(db).Handle - si.handlers.requestDelete = request_delete.New(db).Handle + si.handlers.requestDelete = request_delete.New(appCtx, db, pubSub).Handle si.handlers.appVersion = version.New(appVersion.Version()).Handle si.handlers.appVersionLatest = version_latest.New(lastAppVer).Handle si.handlers.readinessProbe = ready.New(rdyChecker).Handle @@ -110,6 +115,33 @@ func (o *OpenAPI) ApiSessionCreate(w http.ResponseWriter, r *http.Request) { } } +func (o *OpenAPI) ApiSessionCheckExists(w http.ResponseWriter, r *http.Request) { + var payload openapi.CheckSessionExistsRequest + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + o.errorToJson(w, err, http.StatusBadRequest) + + return + } + + const minIDsCount, maxIDsCount = 1, 100 + + if len(payload) < minIDsCount || len(payload) > maxIDsCount { + o.errorToJson(w, + fmt.Errorf("wrong IDs count (should be between %d and %d)", minIDsCount, maxIDsCount), + http.StatusBadRequest, + ) + + return + } + + if resp, err := o.handlers.sessionCheckExists(r.Context(), payload); err != nil { + o.errorToJson(w, err, http.StatusInternalServerError) + } else { + o.respToJson(w, resp) + } +} + func (o *OpenAPI) ApiSessionGet(w http.ResponseWriter, r *http.Request, sID sID) { if resp, err := o.handlers.sessionGet(r.Context(), sID); err != nil { var statusCode = http.StatusInternalServerError diff --git a/internal/http/server.go b/internal/http/server.go index c53c3429..bd0d8ec2 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -65,13 +65,13 @@ func (s *Server) Register( lastAppVer func(context.Context) (string, error), cfg *config.AppSettings, db storage.Storage, - pubSub pubsub.PubSub[pubsub.CapturedRequest], + pubSub pubsub.PubSub[pubsub.RequestEvent], useLiveFrontend bool, ) *Server { var ( - oAPI = NewOpenAPI(log, rdyChk, lastAppVer, cfg, db, pubSub) // OpenAPI server implementation - spa = frontend.New(web.Dist(useLiveFrontend)) // file server for SPA (also handles 404 errors) - mux = http.NewServeMux() // base router for the OpenAPI server + oAPI = NewOpenAPI(ctx, log, rdyChk, lastAppVer, cfg, db, pubSub) // OpenAPI server implementation + spa = frontend.New(web.Dist(useLiveFrontend)) // file server for SPA (also handles 404 errors) + mux = http.NewServeMux() // base router for the OpenAPI server handler = openapi.HandlerWithOptions(oAPI, openapi.StdHTTPServerOptions{ ErrorHandlerFunc: oAPI.HandleInternalError, // set error handler for internal server errors BaseRouter: mux, diff --git a/internal/http/server_test.go b/internal/http/server_test.go index 16afef9a..8b8b5221 100644 --- a/internal/http/server_test.go +++ b/internal/http/server_test.go @@ -50,7 +50,7 @@ func TestServer_StartHTTP(t *testing.T) { func(context.Context) (string, error) { return "v1.0.0", nil }, &config.AppSettings{}, db, - pubsub.NewInMemory[pubsub.CapturedRequest](), + pubsub.NewInMemory[pubsub.RequestEvent](), false, ) diff --git a/internal/pubsub/pubsub.go b/internal/pubsub/pubsub.go index 0e9411a6..f39ee0d2 100644 --- a/internal/pubsub/pubsub.go +++ b/internal/pubsub/pubsub.go @@ -23,7 +23,12 @@ type PubSub[T any] interface { } type ( - CapturedRequest struct { + RequestEvent struct { + Action RequestAction `json:"action"` + Request *Request `json:"request"` + } + + Request struct { ID string `json:"id"` ClientAddr string `json:"client_addr"` Method string `json:"method"` @@ -36,4 +41,12 @@ type ( Name string `json:"name"` Value string `json:"value"` } + + RequestAction = string +) + +const ( + RequestActionCreate RequestAction = "create" // create a request + RequestActionDelete RequestAction = "delete" // delete a request + RequestActionClear RequestAction = "clear" // delete all requests ) diff --git a/web/package-lock.json b/web/package-lock.json index 135f3502..74c670e2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,9 @@ "@mantine/notifications": "^7.13.4", "@tabler/icons-react": "^3.21.0", "dayjs": "^1.11.13", + "dexie": "^4.0.9", + "dexie-react-hooks": "^1.1.7", + "human-id": "^4.1.1", "openapi-fetch": "^0.13.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -2234,14 +2237,12 @@ "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", - "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3467,6 +3468,23 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dexie": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.9.tgz", + "integrity": "sha512-VQG1huEVSAdDZssb9Bb9mFy+d3jAE0PT4d1nIRYlT46ip1fzbs1tXi0SlUayRDgV3tTbJG8ZRqAo2um49gtynA==", + "license": "Apache-2.0" + }, + "node_modules/dexie-react-hooks": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/dexie-react-hooks/-/dexie-react-hooks-1.1.7.tgz", + "integrity": "sha512-Lwv5W0Hk+uOW3kGnsU9GZoR1er1B7WQ5DSdonoNG+focTNeJbHW6vi6nBoX534VKI3/uwHebYzSw1fwY6a7mTw==", + "license": "Apache-2.0", + "peerDependencies": { + "@types/react": ">=16", + "dexie": "^3.2 || ^4.0.1-alpha", + "react": ">=16" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4623,6 +4641,15 @@ "node": ">= 14" } }, + "node_modules/human-id": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.1.tgz", + "integrity": "sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==", + "license": "MIT", + "bin": { + "human-id": "dist/cli.js" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", diff --git a/web/package.json b/web/package.json index bad363d1..65757607 100644 --- a/web/package.json +++ b/web/package.json @@ -20,6 +20,9 @@ "@mantine/notifications": "^7.13.4", "@tabler/icons-react": "^3.21.0", "dayjs": "^1.11.13", + "dexie": "^4.0.9", + "dexie-react-hooks": "^1.1.7", + "human-id": "^4.1.1", "openapi-fetch": "^0.13.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 06be2126..14cae6e6 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -3,7 +3,7 @@ import { coerce as semverCoerce, parse as semverParse, type SemVer } from 'semve import { base64ToUint8Array, uint8ArrayToBase64 } from '~/shared' import { APIErrorUnknown } from './errors' import { throwIfNotJSON, throwIfNotValidResponse } from './middleware' -import { components, paths } from './schema.gen' +import { components, paths, type RequestEventAction } from './schema.gen' type AppSettings = Readonly<{ limits: Readonly<{ @@ -28,17 +28,29 @@ type SessionOptions = Readonly<{ createdAt: Readonly }> +type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE' | string + type CapturedRequest = Readonly<{ uuid: string clientAddress: string - method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE' | string + method: HttpMethod requestPayload: Uint8Array headers: ReadonlyArray<{ name: string; value: string }> url: Readonly capturedAt: Readonly }> -type CapturedRequestShort = Omit +type RequestEvent = Readonly<{ + action: RequestEventAction + request: { + uuid: string + clientAddress: string + method: HttpMethod + headers: ReadonlyArray<{ name: string; value: string }> + url: Readonly + capturedAt: Readonly + } | null +}> export class Client { private readonly baseUrl: URL @@ -220,6 +232,34 @@ export class Client { throw new APIErrorUnknown({ message: response.statusText, response }) } + /** + * Batch checking the existence of the sessions by their IDs. + * + * @throws {APIError} + */ + async checkSessionExists(...ids: Array): Promise<{ [K in T]: boolean }> { + const { data, response } = await this.api.POST('/api/session/check/exists', { + body: ids, + }) + + if (data) { + return Object.freeze( + ids.reduce( + (acc, id) => { + acc[id] = Object.keys(data).includes(id) + + return acc + }, + {} as { + [K in T]: boolean + } + ) + ) + } + + throw new APIErrorUnknown({ message: response.statusText, response }) + } + /** * Deletes the session by its ID. * @@ -300,7 +340,7 @@ export class Client { onError, }: { onConnected?: () => void // called when the WebSocket connection is established - onUpdate: (request: CapturedRequestShort) => void // called when the update is received + onUpdate: (request: RequestEvent) => void // called when the update is received onError?: (err: Error) => void // called when an error occurs on alive connection } ): Promise void> { @@ -332,18 +372,22 @@ export class Client { ws.onmessage = (event): void => { if (event.data) { - const req = JSON.parse(event.data) as components['schemas']['CapturedRequestShort'] - - onUpdate( - Object.freeze({ - uuid: req.uuid, - clientAddress: req.client_address, - method: req.method, - headers: Object.freeze(req.headers), - url: Object.freeze(new URL(req.url)), - capturedAt: Object.freeze(new Date(req.captured_at_unix_milli)), - }) - ) + const req = JSON.parse(event.data) as components['schemas']['RequestEvent'] + const payload: RequestEvent = { + action: req.action, + request: req.request + ? Object.freeze({ + uuid: req.request.uuid, + clientAddress: req.request.client_address, + method: req.request.method, + headers: Object.freeze(req.request.headers), + url: Object.freeze(new URL(req.request.url)), + capturedAt: Object.freeze(new Date(req.request.captured_at_unix_milli)), + }) + : null, + } + + onUpdate(Object.freeze(payload)) } } } catch (e) { diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 2aaa6f79..31f6646b 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -1,2 +1,3 @@ export { Client } from './client' export { type APIError, APIErrorNotFound, APIErrorCommon, APIErrorUnknown } from './errors' +export { RequestEventAction } from './schema.gen' diff --git a/web/src/db/database.ts b/web/src/db/database.ts new file mode 100644 index 00000000..23331b8b --- /dev/null +++ b/web/src/db/database.ts @@ -0,0 +1,196 @@ +import { Dexie } from 'dexie' +import { DatabaseError } from './errors' +import { SessionsTable, Session, sessionsSchema, RequestsTable, Request, requestsSchema } from './tables' + +export class Database { + public dexie: Dexie + private readonly sessions: SessionsTable + private readonly requests: RequestsTable + + constructor() { + // create database + this.dexie = new Dexie('webhook-tester-v2-db') // https://dexie.org/docs/Typescript + this.dexie.version(1).stores({ ...sessionsSchema, ...requestsSchema }) + + // assign tables + this.sessions = this.dexie.table('sessions') + this.requests = this.dexie.table('requests') + } + + /** + * Insert a new session (the existing session with the same sID will be replaced). + * + * @throws {DatabaseError} If the operation fails + */ + async createSession(...data: Array): Promise { + try { + await this.dexie.transaction('rw', this.sessions, async () => { + await this.sessions.bulkPut(data) + }) + } catch (err) { + throw new DatabaseError('Failed to create session', err) + } + } + + /** + * Get all available session IDs, ordered by creation date from the newest to the oldest. + * + * @throws {DatabaseError} If the operation fails + */ + async getSessionIDs(): Promise> { + try { + return await this.dexie.transaction('r', this.sessions, async () => { + return (await this.sessions.toCollection().sortBy('createdAt')).reverse().map((session) => session.sID) + }) + } catch (err) { + throw new DatabaseError('Failed to get session IDs', err) + } + } + + /** + * Get the session by sID. + * + * @throws {DatabaseError} If the operation fails + */ + async getSession(sID: string): Promise { + try { + return await this.dexie.transaction('r', this.sessions, async () => { + return (await this.sessions.get(sID)) || null + }) + } catch (err) { + throw new DatabaseError('Failed to get session', err) + } + } + + /** + * Get many sessions by its sID. + * + * @throws {DatabaseError} If the operation fails + */ + async getSessions(...sID: Array): Promise<{ [K in T]: Session | null }> { + try { + return await this.dexie.transaction('r', this.sessions, async () => { + const sessions = await this.sessions.where('sID').anyOf(sID).toArray() + + return sID.reduce( + (acc, sID_1) => { + acc[sID_1] = sessions.find((session) => session.sID === sID_1) || null + + return acc + }, + {} as { + [K in T]: Session | null + } + ) + }) + } catch (err) { + throw new DatabaseError('Failed to get sessions', err) + } + } + + /** + * Check if a session exists. + * + * @throws {DatabaseError} If the operation fails + */ + async sessionExists(sID: string): Promise { + try { + return await this.dexie.transaction('r', this.sessions, async () => { + return (await this.sessions.where('sID').equals(sID).count()) > 0 + }) + } catch (err) { + throw new DatabaseError('Failed to check if session exists', err) + } + } + + /** + * Get all session requests, ordered by creation date from the newest to the oldest. + * + * @throws {DatabaseError} If the operation fails + */ + async getSessionRequests(sID: string): Promise> { + try { + return await this.dexie.transaction('r', this.requests, async () => { + return (await this.requests.where('sID').equals(sID).sortBy('capturedAt')).reverse() + }) + } catch (err) { + throw new DatabaseError('Failed to get session requests', err) + } + } + + /** + * Delete session (and all requests associated with it). + * + * @throws {DatabaseError} If the operation fails + */ + async deleteSession(...sID: Array): Promise { + try { + await this.dexie.transaction('rw', this.sessions, this.requests, async () => { + await this.sessions.bulkDelete(sID) + await this.requests.where('sID').anyOf(sID).delete() + }) + } catch (err) { + throw new DatabaseError('Failed to delete session', err) + } + } + + /** + * Insert a new request (the existing request with the same rID will be replaced). + * + * @throws {DatabaseError} If the operation fails + */ + async createRequest(...data: Array): Promise { + try { + await this.dexie.transaction('rw', this.requests, async () => { + await this.requests.bulkPut(data) + }) + } catch (err) { + throw new DatabaseError('Failed to create request', err) + } + } + + /** + * Get a request by rID. + * + * @throws {DatabaseError} If the operation fails + */ + async getRequest(rID: string): Promise { + try { + return await this.dexie.transaction('r', this.requests, async () => { + return (await this.requests.get(rID)) || null + }) + } catch (err) { + throw new DatabaseError('Failed to get request', err) + } + } + + /** + * Delete requests by rID. + * + * @throws {DatabaseError} If the operation fails + */ + async deleteRequest(...rID: Array): Promise { + try { + await this.dexie.transaction('rw', this.requests, async () => { + await this.requests.bulkDelete(rID) + }) + } catch (err) { + throw new DatabaseError('Failed to delete request', err) + } + } + + /** + * Delete all requests associated with a session. + * + * @throws {DatabaseError} If the operation fails + */ + async deleteAllRequests(sID: string): Promise { + try { + await this.dexie.transaction('rw', this.requests, async () => { + await this.requests.where('sID').equals(sID).delete() + }) + } catch (err) { + throw new DatabaseError('Failed to delete all requests', err) + } + } +} diff --git a/web/src/db/errors.ts b/web/src/db/errors.ts new file mode 100644 index 00000000..79f57621 --- /dev/null +++ b/web/src/db/errors.ts @@ -0,0 +1,18 @@ +/** Custom error class for database errors */ +export class DatabaseError extends Error { + public readonly original?: Error + + constructor(message: string, original?: Error | unknown) { + super(message) + + if (original instanceof Error) { + this.original = original + } else if (original) { + this.original = new Error(String(original)) + } else { + this.original = undefined + } + + this.name = 'DatabaseError' + } +} diff --git a/web/src/db/index.ts b/web/src/db/index.ts new file mode 100644 index 00000000..22526646 --- /dev/null +++ b/web/src/db/index.ts @@ -0,0 +1,2 @@ +export { Database } from './database' +export { DatabaseError } from './errors' diff --git a/web/src/db/tables/index.ts b/web/src/db/tables/index.ts new file mode 100644 index 00000000..c9a97cf4 --- /dev/null +++ b/web/src/db/tables/index.ts @@ -0,0 +1,2 @@ +export { type Session, type SessionsTable, sessionsSchema } from './sessions.ts' +export { type Request, type RequestsTable, requestsSchema } from './requests.ts' diff --git a/web/src/db/tables/requests.ts b/web/src/db/tables/requests.ts new file mode 100644 index 00000000..fa0bbefd --- /dev/null +++ b/web/src/db/tables/requests.ts @@ -0,0 +1,17 @@ +import { Table } from 'dexie' + +export type Request = { + sID: string + rID: string + clientAddress: string + method: string + headers: Array<{ name: string; value: string }> + url: URL + capturedAt: Date +} + +export type RequestsTable = Table + +export const requestsSchema = { + requests: '&rID, sID', +} diff --git a/web/src/db/tables/sessions.ts b/web/src/db/tables/sessions.ts new file mode 100644 index 00000000..acca9e6c --- /dev/null +++ b/web/src/db/tables/sessions.ts @@ -0,0 +1,13 @@ +import { Table } from 'dexie' + +export type Session = { + sID: string + humanReadableName: string + createdAt: Date +} + +export type SessionsTable = Table + +export const sessionsSchema = { + sessions: '&sID', +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 0eae9dbd..fd6249ef 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -6,28 +6,32 @@ import { Notifications } from '@mantine/notifications' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import { Client } from './api' +import { Database } from './db' import { createRoutes } from './routing' -import { BrowserNotificationsProvider, SessionsProvider, UISettingsProvider } from './shared' import '@mantine/core/styles.css' import '@mantine/code-highlight/styles.css' import '@mantine/notifications/styles.css' import '~/theme/app.css' +import { BrowserNotificationsProvider, DataProvider, SessionsProvider, UISettingsProvider } from './shared' dayjs.extend(relativeTime) // https://day.js.org/docs/en/plugin/relative-time /** App component */ const App = (): React.JSX.Element => { - const apiClient = new Client() + const api = new Client() + const db = new Database() return ( - - - - - + + + + + + + ) diff --git a/web/src/screens/components/header.tsx b/web/src/screens/components/header.tsx index 1ab90c3f..6aed48af 100644 --- a/web/src/screens/components/header.tsx +++ b/web/src/screens/components/header.tsx @@ -16,7 +16,7 @@ import React, { useCallback, useEffect, useState } from 'react' import { Link } from 'react-router-dom' import type { SemVer } from 'semver' import LogoTextSvg from '~/assets/logo-text.svg' -import { useBrowserNotifications, useSessions, useUISettings } from '~/shared' +import { useSessions, useBrowserNotifications, useUISettings } from '~/shared' import HeaderHelpModal from './header-help-modal' import HeaderNewSessionModal, { type NewSessionOptions } from './header-new-session-modal' diff --git a/web/src/screens/home/screen.tsx b/web/src/screens/home/screen.tsx index 6d54b460..d505c1f8 100644 --- a/web/src/screens/home/screen.tsx +++ b/web/src/screens/home/screen.tsx @@ -8,8 +8,7 @@ import { useSessions } from '~/shared' export default function HomeScreen({ apiClient }: { apiClient: Client }): React.JSX.Element { const [navigate, { hash }] = [useNavigate(), useLocation()] - const { addSession } = useSessions() - const { sessions, lastUsed, setLastUsed } = useSessions() + const { sessions, lastUsed, setLastUsed, addSession } = useSessions() useEffect(() => { if (hash) { diff --git a/web/src/screens/layout.tsx b/web/src/screens/layout.tsx index 10d05c9d..28c29c4f 100644 --- a/web/src/screens/layout.tsx +++ b/web/src/screens/layout.tsx @@ -7,7 +7,7 @@ import { Outlet, useNavigate, useOutletContext } from 'react-router-dom' import type { SemVer } from 'semver' import { type Client } from '~/api' import { pathTo, RouteIDs } from '~/routing' -import { sessionToUrl, useSessions } from '~/shared' +import { useSessions, sessionToUrl } from '~/shared' import { Header, type ListedRequest, type NewSessionOptions, SideBar } from './components' export default function DefaultLayout({ apiClient }: { apiClient: Client }): React.JSX.Element { diff --git a/web/src/screens/session/screen.tsx b/web/src/screens/session/screen.tsx index f2bf1d43..aa1c8651 100644 --- a/web/src/screens/session/screen.tsx +++ b/web/src/screens/session/screen.tsx @@ -3,9 +3,9 @@ import { notifications as notify } from '@mantine/notifications' import { IconInfoCircle, IconRocket } from '@tabler/icons-react' import React, { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { APIErrorCommon, APIErrorNotFound, type Client } from '~/api' +import { APIErrorCommon, APIErrorNotFound, type Client, RequestEventAction } from '~/api' import { pathTo, RouteIDs } from '~/routing' -import { sessionToUrl, useBrowserNotifications, useSessions, useUISettings } from '~/shared' +import { sessionToUrl, useUISettings, useSessions, useBrowserNotifications } from '~/shared' import { useLayoutOutletContext } from '../layout' import { RequestDetails, SessionDetails, type SessionProps } from './components' @@ -43,64 +43,85 @@ export default function SessionAndRequestScreen({ apiClient }: { apiClient: Clie apiClient .subscribeToSessionRequests(sID, { - onUpdate: (request): void => { - // append the new request in front of the list - setListedRequests((prev) => { - let newList = [ - Object.freeze({ - id: request.uuid, - method: request.method, - clientAddress: request.clientAddress, - capturedAt: request.capturedAt, - }), - ...prev, - ] - - // limit the number of shown requests per session if the setting is set and the list is too long - if ( - !!appSettingsRef.current && - appSettingsRef.current.setMaxRequestsPerSession && - newList.length > appSettingsRef.current.setMaxRequestsPerSession - ) { - newList = newList.slice(0, appSettingsRef.current.setMaxRequestsPerSession) - } - - return newList - }) - - // the in-app notification function to show the new request notification - const showInAppNotification = (): void => { - notify.show({ - title: 'New request received', - message: `From ${request.clientAddress} with method ${request.method}`, - icon: , - color: 'blue', - }) - } + onUpdate: (requestEvent): void => { + switch (requestEvent.action) { + case RequestEventAction.create: { + if (requestEvent.request) { + const req = requestEvent.request + + // append the new request in front of the list + setListedRequests((prev) => { + let newList = [ + Object.freeze({ + id: req.uuid, + method: req.method, + clientAddress: req.clientAddress, + capturedAt: req.capturedAt, + }), + ...prev, + ] + + // limit the number of shown requests per session if the setting is set and the list is too long + if ( + !!appSettingsRef.current && + appSettingsRef.current.setMaxRequestsPerSession && + newList.length > appSettingsRef.current.setMaxRequestsPerSession + ) { + newList = newList.slice(0, appSettingsRef.current.setMaxRequestsPerSession) + } + + return newList + }) + + // the in-app notification function to show the new request notification + const showInAppNotification = (): void => { + notify.show({ + title: 'New request received', + message: `From ${req.clientAddress} with method ${req.method}`, + icon: , + color: 'blue', + }) + } - // show a notification about the new request using the browser's native notification API, - // if the permission is granted and the setting is enabled - if (browserNotificationsGrantedRef.current && uiSettings.current.showNativeRequestNotifications) { - showBrowserNotification('New request received', { - body: `From ${request.clientAddress} with method ${request.method}`, - autoClose: 5000, - }) - // in case the notification is not shown, show the in-app notification - .then((n) => { - if (!n) { + // show a notification about the new request using the browser's native notification API, + // if the permission is granted and the setting is enabled + if (browserNotificationsGrantedRef.current && uiSettings.current.showNativeRequestNotifications) { + showBrowserNotification('New request received', { + body: `From ${req.clientAddress} with method ${req.method}`, + autoClose: 5000, + }) + // in case the notification is not shown, show the in-app notification + .then((n) => { + if (!n) { + showInAppNotification() + } + }) + // do the same in case of an error + .catch(showInAppNotification) + } else { + // otherwise, show the in-app notification showInAppNotification() } - }) - // do the same in case of an error - .catch(showInAppNotification) - } else { - // otherwise, show the in-app notification - showInAppNotification() - } - // navigate to the new request if the setting is enabled - if (uiSettings.current.autoNavigateToNewRequest) { - navigate(pathTo(RouteIDs.SessionAndRequest, sID, request.uuid)) // navigate to the new request + // navigate to the new request if the setting is enabled + if (uiSettings.current.autoNavigateToNewRequest) { + navigate(pathTo(RouteIDs.SessionAndRequest, sID, req.uuid)) // navigate to the new request + } + } + + break + } + + case RequestEventAction.delete: { + if (requestEvent.request) { + // onRequestDelete?.(sID, requestEvent.request.uuid) + // TODO: Implement requests storage + } + + break + } + + // TODO: handle other actions } }, onError: (error): void => { diff --git a/web/src/shared/use-storage.ts b/web/src/shared/hooks/use-storage.ts similarity index 100% rename from web/src/shared/use-storage.ts rename to web/src/shared/hooks/use-storage.ts diff --git a/web/src/shared/index.ts b/web/src/shared/index.ts index 8d41f221..feeb5186 100644 --- a/web/src/shared/index.ts +++ b/web/src/shared/index.ts @@ -1,7 +1,7 @@ -export { base64ToUint8Array, uint8ArrayToBase64 } from './base64' -export { useStorage, UsedStorageKeys } from './use-storage' -export { sessionToUrl } from './url' -export { UISettingsProvider, useUISettings } from './ui-settings-provider' -export { SessionsProvider, useSessions } from './sessions-provider' -export { useVisibilityChange } from './use-visibility-change' -export { BrowserNotificationsProvider, useBrowserNotifications } from './browser-notifications-provider' +export { base64ToUint8Array, uint8ArrayToBase64 } from './utils/encoding' +export { sessionToUrl } from './utils/url' +export { useStorage, UsedStorageKeys } from './hooks/use-storage' +export { BrowserNotificationsProvider, useBrowserNotifications } from './providers/browser-notifications' +export { SessionsProvider, useSessions } from './providers/sessions' +export { UISettingsProvider, useUISettings } from './providers/ui-settings' +export { DataProvider, useData } from './providers/data' diff --git a/web/src/shared/browser-notifications-provider.tsx b/web/src/shared/providers/browser-notifications.tsx similarity index 100% rename from web/src/shared/browser-notifications-provider.tsx rename to web/src/shared/providers/browser-notifications.tsx diff --git a/web/src/shared/providers/data.tsx b/web/src/shared/providers/data.tsx new file mode 100644 index 00000000..b4f5f64c --- /dev/null +++ b/web/src/shared/providers/data.tsx @@ -0,0 +1,450 @@ +import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' +import { humanId } from 'human-id' +import { type Client, RequestEventAction } from '~/api' +import { Database } from '~/db' +import { UsedStorageKeys, useStorage } from '~/shared' + +export type Session = { + sID: string + humanReadableName: string +} + +export type Request = { + rID: string + clientAddress: string // IPv4 or IPv6 + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE' | string + headers: Array<{ name: string; value: string }> + url: URL + payload: Uint8Array | null + capturedAt: Date +} + +type DataContext = { + /** The last used session ID (updates every time a session is switched) */ + lastUsedSID: string | null + + /** Create a new session */ + newSession({ + statusCode, + headers, + delay, + responseBody, + }: { + statusCode?: number + headers?: Record + delay?: number + responseBody?: Uint8Array + }): Promise + + /** Switch to a session with the given ID. It returns `true` if the session was switched successfully. */ + switchToSession(sID: string): Promise + + /** Current active session */ + session: Readonly | null + + /** The list of all session IDs, available to the user */ + allSessionIDs: ReadonlyArray + + /** Destroy a session with the given ID */ + destroySession(sID: string): Promise + + /** Current active request */ + request: Readonly | null + + /** The list of requests for the current session, ordered by the captured time (from newest to oldest) */ + requests: ReadonlyArray> // omit the payload to reduce the memory usage + + /** Switch to a request with the given ID for the current session */ + switchToRequest(rID: string): Promise + + /** Remove a request with the given ID for the current session */ + removeRequest(rID: string): Promise +} + +const notInitialized = () => { + throw new Error('DataProvider is not initialized') +} + +const dataContext = createContext({ + lastUsedSID: null, + newSession: () => notInitialized(), + switchToSession: () => notInitialized(), + session: null, + allSessionIDs: [], + destroySession: () => notInitialized(), + request: null, + requests: [], + switchToRequest: () => notInitialized(), + removeRequest: () => notInitialized(), +}) + +// TODO: use notifications for error handling? +const errHandler = (err: Error | unknown) => console.error(err) + +/** Sort requests by the captured time (from newest to oldest) */ +const requestsSorter = (a: T, b: T) => b.capturedAt.getTime() - a.capturedAt.getTime() + +/** + * DataProvider is a context provider that manages application data. + * + * Think of it as the **core** of the business logic, handling all data and key methods related to sessions and requests. + */ +export const DataProvider = ({ api, db, children }: { api: Client; db: Database; children: React.JSX.Element }) => { + const [lastUsedSID, setLastUsedSID] = useStorage(null, UsedStorageKeys.SessionsLastUsed, 'local') + const [session, setSession] = useState | null>(null) + const [allSessionIDs, setAllSessionIDs] = useState>([]) + const [request, setRequest] = useState | null>(null) + const [requests, setRequests] = useState>>([]) + + // the subscription closer function (if not null, it means the subscription is active) + const closeSubRef = useRef<(() => void) | null>(null) + + /** Create a new session */ + const newSession = useCallback( + ({ + statusCode = 200, // default session options + headers = {}, + delay = 0, + responseBody = new Uint8Array(), + }: { + statusCode?: number + headers?: Record + delay?: number + responseBody?: Uint8Array + }): Promise => { + return new Promise((resolve, reject) => { + // save the session to the server + api + .newSession({ statusCode, headers, delay, responseBody }) + .then((opts) => { + const humanReadableName = humanId() + + // save the session to the database + db.createSession({ sID: opts.uuid, humanReadableName, createdAt: opts.createdAt }) + .then(() => { + // add the session ID to the list of all session IDs + setAllSessionIDs((prev) => [...prev, opts.uuid]) + // empty the requests list + setRequests([]) + + resolve({ sID: opts.uuid, humanReadableName }) + }) + .catch(reject) + }) + .catch(reject) + }) + }, + [api, db] + ) + + /** Switch to a session with the given ID. It returns `true` if the session was switched successfully. */ + const switchToSession = useCallback( + (sID: string) => { + return new Promise((resolve, reject) => { + // subscribe to the session requests + if (closeSubRef.current) { + closeSubRef.current() + } + + // get the session from the database + db.getSession(sID) + .then((opts) => { + if (opts) { + // set the session as the current session + setSession({ sID: opts.sID, humanReadableName: opts.humanReadableName }) + // update the last used session ID + setLastUsedSID(opts.sID) + + // load requests for the session from the database (fast) + db.getSessionRequests(opts.sID) + .then((reqs) => { + setRequests( + reqs + .map((r) => ({ + rID: r.rID, + clientAddress: r.clientAddress, + method: r.method, + headers: r.headers.map((h) => ({ name: h.name, value: h.value })), + url: r.url, + capturedAt: r.capturedAt, + })) + .sort(requestsSorter) + ) + + // load requests from the server (slow) + api + .getSessionRequests(opts.sID) + .then((reqs) => { + setRequests( + reqs + .map((r) => ({ + rID: r.uuid, + clientAddress: r.clientAddress, + method: r.method, + headers: r.headers.map((h) => ({ name: h.name, value: h.value })), + url: r.url, + capturedAt: r.capturedAt, + })) + .sort(requestsSorter) + ) + + // update the requests in the database (for the future use) + db.createRequest( + ...reqs + .map((r) => ({ + sID: opts.sID, + rID: r.uuid, + method: r.method, + clientAddress: r.clientAddress, + url: new URL(r.url), + capturedAt: r.capturedAt, + headers: r.headers.map((h) => ({ name: h.name, value: h.value })), + })) + .sort(requestsSorter) + ).catch(errHandler) + }) + .catch(errHandler) + + // subscribe to the session requests on the server + api + .subscribeToSessionRequests(opts.sID, { + onUpdate: (requestEvent): void => { + switch (requestEvent.action) { + // a new request was captured + case RequestEventAction.create: { + const req = requestEvent.request + + if (req) { + // append the new request in front of the list + setRequests((prev) => [ + { + rID: req.uuid, + clientAddress: req.clientAddress, + method: req.method, + headers: req.headers.map((h) => ({ name: h.name, value: h.value })), + url: req.url, + capturedAt: req.capturedAt, + }, + ...prev, + ]) + + // TODO: add limit for the number of requests per session + // TODO: show notifications for new requests + + // save the request to the database + db.createRequest({ + sID: opts.sID, + rID: req.uuid, + method: req.method, + clientAddress: req.clientAddress, + url: new URL(req.url), + capturedAt: req.capturedAt, + headers: req.headers.map((h) => ({ name: h.name, value: h.value })), + }).catch(errHandler) + } + + break + } + + // a request was deleted + case RequestEventAction.delete: { + const req = requestEvent.request + + if (req) { + // remove the request from the list + setRequests((prev) => prev.filter((r) => r.rID !== req.uuid)) + + // remove the request from the database + db.deleteRequest(req.uuid).catch(errHandler) + } + + break + } + + // all requests were cleared + case RequestEventAction.clear: { + // clear the requests list + setRequests([]) + + // clear the requests from the database + db.deleteAllRequests(opts.sID).catch(errHandler) + + break + } + } + }, + onError: (error) => errHandler(error), + }) + .then((closer) => (closeSubRef.current = closer)) + .catch(errHandler) + }) + .catch(errHandler) + + return resolve(true) + } + + return resolve(false) + }) + .catch(reject) + }) + }, + [api, db, setLastUsedSID] + ) + + /** Destroy a session with the given ID */ + const destroySession = useCallback( + (sID: string): Promise => { + return new Promise((resolve, reject) => { + // remove the session from the database first + db.deleteSession(sID) + .then(() => { + // remove the session from the list of all session IDs + setAllSessionIDs((prev) => prev.filter((id) => id !== sID)) + + // if the session is the current session, unset the current session + if (!!session && session.sID === sID) { + setSession(null) + } + + // remove from the server too + api.deleteSession(sID).catch(errHandler) + + resolve() + }) + .catch(reject) + }) + }, + [api, db, session] + ) + + /** Switch to a request with the given ID for the current session */ + const switchToRequest = useCallback( + (rID: string): Promise => { + if (!session) { + return Promise.resolve(false) + } + + return new Promise((resolve, reject) => { + // get the request from the database (fast) + db.getRequest(rID) + .then((req) => { + if (req) { + // set the current request with the data from the database, except the payload + setRequest({ + rID: req.rID, + clientAddress: req.clientAddress, + method: req.method, + headers: req.headers.map((h) => ({ name: h.name, value: h.value })), + url: req.url, + payload: null, // database does not store the payload + capturedAt: req.capturedAt, + }) + + // get the request payload from the server (slow) + api + .getSessionRequest(session.sID, rID) + .then((req) => { + setRequest((prev) => { + if (prev) { + return { ...prev, payload: req.requestPayload } + } + + return prev + }) + }) + .catch(reject) + } else { + resolve(false) + } + }) + .catch(reject) + }) + }, + [api, db, session] + ) + + /** Remove a request with the given ID for the current session */ + const removeRequest = useCallback( + (rID: string): Promise => { + return new Promise((resolve, reject) => { + // remove the request from the database + db.deleteRequest(rID) + .then(() => { + // update the requests list + setRequests((prev) => prev.filter((r) => r.rID !== rID).sort(requestsSorter)) + + // remove from the server, if session is active + if (session) { + api.deleteSessionRequest(session.sID, rID).catch(errHandler) + } + + resolve() + }) + .catch(reject) + }) + }, + [api, db, session] + ) + + // on provider mount + useEffect(() => { + // load all session IDs from the database + db.getSessionIDs() + .then((dbSessionIDs) => { + // set the initial list of session IDs (fast) + setAllSessionIDs(dbSessionIDs) + + if (dbSessionIDs.length) { + // if we have any session IDs, check the sessions existence on the server to invalidate the ones that do not + api + .checkSessionExists(...dbSessionIDs) + .then((checkResult) => { + // filter out the IDs that do not exist on the server + const toRemove = dbSessionIDs.filter((id) => !checkResult[id]) + + // if we have any IDs to remove + if (toRemove.length) { + // cleanup the database + db.deleteSession(...toRemove) + .then(() => { + // update the list of session IDs + setAllSessionIDs((prev) => prev.filter((id) => !toRemove.includes(id))) + }) + .catch(errHandler) + } + }) + .catch(errHandler) + } + }) + .catch(errHandler) + }, [api, db]) + + return ( + + {children} + + ) +} + +export function useData(): DataContext { + const ctx = useContext(dataContext) + + if (!ctx) { + throw new Error('useData must be used within a DataProvider') + } + + return ctx +} diff --git a/web/src/shared/sessions-provider.tsx b/web/src/shared/providers/sessions.tsx similarity index 97% rename from web/src/shared/sessions-provider.tsx rename to web/src/shared/providers/sessions.tsx index 5594d294..1ea75b30 100644 --- a/web/src/shared/sessions-provider.tsx +++ b/web/src/shared/providers/sessions.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext } from 'react' -import { UsedStorageKeys, useStorage } from './use-storage' +import { UsedStorageKeys, useStorage } from '../hooks/use-storage.ts' type SessionsContext = { sessions: ReadonlyArray diff --git a/web/src/shared/ui-settings-provider.tsx b/web/src/shared/providers/ui-settings.tsx similarity index 95% rename from web/src/shared/ui-settings-provider.tsx rename to web/src/shared/providers/ui-settings.tsx index 20394e0e..39a3c5c4 100644 --- a/web/src/shared/ui-settings-provider.tsx +++ b/web/src/shared/providers/ui-settings.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, useEffect, useRef } from 'react' -import { UsedStorageKeys, useStorage } from './use-storage' +import { UsedStorageKeys, useStorage } from '../hooks/use-storage.ts' type UISettings = { showRequestDetails: boolean diff --git a/web/src/shared/use-visibility-change.ts b/web/src/shared/use-visibility-change.ts deleted file mode 100644 index ebd96616..00000000 --- a/web/src/shared/use-visibility-change.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useEffect, useState } from 'react' - -/** - * Hook that returns the visibility state of the document. - * - * @link https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilitychange_event - */ -export function useVisibilityChange(): boolean { - const [isVisible, setIsVisible] = useState(document.visibilityState === 'visible') - - useEffect(() => { - const handleVisibilityChange = () => setIsVisible(document.visibilityState === 'visible') - - document.addEventListener('visibilitychange', handleVisibilityChange) - - return () => document.removeEventListener('visibilitychange', handleVisibilityChange) - }, []) - - return isVisible -} diff --git a/web/src/shared/base64.ts b/web/src/shared/utils/encoding.ts similarity index 100% rename from web/src/shared/base64.ts rename to web/src/shared/utils/encoding.ts diff --git a/web/src/shared/url.ts b/web/src/shared/utils/url.ts similarity index 100% rename from web/src/shared/url.ts rename to web/src/shared/utils/url.ts diff --git a/web/src/theme/color.ts b/web/src/theme/color.ts index ef889277..f1c40876 100644 --- a/web/src/theme/color.ts +++ b/web/src/theme/color.ts @@ -5,17 +5,17 @@ export const methodToColor = ( ): MantineColor => { switch (method.trim().toUpperCase()) { case 'GET': - return 'blue' - case 'POST': return 'green' - case 'PUT': + case 'POST': return 'yellow' + case 'PUT': + return 'blue' case 'PATCH': - return 'purple' + return 'violet' case 'DELETE': return 'red' case 'HEAD': - return 'teal' + return 'green' case 'OPTIONS': return 'orange' case 'TRACE': From 3f50302d5ccc9723a1e78da655d53a2b836967a6 Mon Sep 17 00:00:00 2001 From: Paramtamtam <7326800+tarampampam@users.noreply.github.com> Date: Tue, 12 Nov 2024 01:08:30 +0400 Subject: [PATCH 2/8] =?UTF-8?q?wip:=20=F0=9F=94=95=20temporary=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/openapi.yml | 2 +- tests/k6/run.js | 2 +- web/.gitignore | 1 + web/src/db/tables/requests.ts | 1 + web/src/db/tables/sessions.ts | 4 + web/src/main.tsx | 24 +- web/src/routing/routing.tsx | 4 +- .../components/header-new-session-modal.tsx | 195 ------ web/src/screens/components/header.tsx | 300 --------- .../components/help-modal.tsx} | 31 +- .../components/header/components/index.ts | 4 + .../header/components/new-session-modal.tsx | 281 +++++++++ .../header/components/session-switch.tsx | 97 +++ .../header/components/ui-settings.tsx | 52 ++ web/src/screens/components/header/header.tsx | 185 ++++++ web/src/screens/components/index.ts | 5 +- web/src/screens/components/sidebar.tsx | 288 --------- .../components/sidebar/components/index.ts | 2 + .../sidebar/components/navigator.tsx | 117 ++++ .../components/request.module.css} | 0 .../components/sidebar/components/request.tsx | 116 ++++ .../screens/components/sidebar/sidebar.tsx | 66 ++ web/src/screens/home/index.ts | 2 +- web/src/screens/home/screen.tsx | 65 +- web/src/screens/layout.test.tsx | 41 -- web/src/screens/layout.tsx | 303 +-------- web/src/screens/not-found/index.ts | 2 +- web/src/screens/not-found/screen.tsx | 2 +- web/src/screens/session/components/index.ts | 4 +- .../request-details/components/index.ts | 2 + .../components}/view-hex.tsx | 16 +- .../components}/view-text.tsx | 16 +- .../{ => request-details}/request-details.tsx | 133 ++-- .../session/components/session-details.tsx | 309 +++++----- web/src/screens/session/index.ts | 2 +- web/src/screens/session/screen.tsx | 240 +------- web/src/shared/hooks/use-storage.ts | 4 +- web/src/shared/index.ts | 8 +- web/src/shared/providers/data.tsx | 575 ++++++++++++------ web/src/shared/providers/sessions.tsx | 79 --- web/src/shared/providers/settings.tsx | 68 +++ web/src/shared/providers/ui-settings.tsx | 59 -- web/src/shared/utils/url.ts | 4 - 43 files changed, 1766 insertions(+), 1945 deletions(-) delete mode 100644 web/src/screens/components/header-new-session-modal.tsx delete mode 100644 web/src/screens/components/header.tsx rename web/src/screens/components/{header-help-modal.tsx => header/components/help-modal.tsx} (79%) create mode 100644 web/src/screens/components/header/components/index.ts create mode 100644 web/src/screens/components/header/components/new-session-modal.tsx create mode 100644 web/src/screens/components/header/components/session-switch.tsx create mode 100644 web/src/screens/components/header/components/ui-settings.tsx create mode 100644 web/src/screens/components/header/header.tsx delete mode 100644 web/src/screens/components/sidebar.tsx create mode 100644 web/src/screens/components/sidebar/components/index.ts create mode 100644 web/src/screens/components/sidebar/components/navigator.tsx rename web/src/screens/components/{sidebar.module.css => sidebar/components/request.module.css} (100%) create mode 100644 web/src/screens/components/sidebar/components/request.tsx create mode 100644 web/src/screens/components/sidebar/sidebar.tsx delete mode 100644 web/src/screens/layout.test.tsx create mode 100644 web/src/screens/session/components/request-details/components/index.ts rename web/src/screens/session/components/{ => request-details/components}/view-hex.tsx (98%) rename web/src/screens/session/components/{ => request-details/components}/view-text.tsx (95%) rename web/src/screens/session/components/{ => request-details}/request-details.tsx (64%) delete mode 100644 web/src/shared/providers/sessions.tsx create mode 100644 web/src/shared/providers/settings.tsx delete mode 100644 web/src/shared/providers/ui-settings.tsx delete mode 100644 web/src/shared/utils/url.ts diff --git a/api/openapi.yml b/api/openapi.yml index 724c1d2c..4c58658d 100644 --- a/api/openapi.yml +++ b/api/openapi.yml @@ -64,7 +64,7 @@ paths: '5XX': {$ref: '#/components/responses/ErrorResponse'} # Server error /api/session/{session_uuid}/requests: - get: + get: # TODO: add possibility to omit the request body summary: Get the list of requests for a session by UUID tags: [api] operationId: apiSessionListRequests diff --git a/tests/k6/run.js b/tests/k6/run.js index 7a65941c..472ac3d1 100644 --- a/tests/k6/run.js +++ b/tests/k6/run.js @@ -53,7 +53,7 @@ export default (ctx) => { const sID = testApiCreateSession(baseUrl, statusCode, [{name: headerName, value: headerValue}], responseBody) - testApiSessionGet(baseUrl, sID, {statusCode, headers: [{name: headerName, value: headerValue}], responseBody}) + testApiSessionGet(baseUrl, sID, {statusCode: status, head: [{name: headerName, value: headerValue}], responseBody: body}) group('requests', () => { testApiSessionHasNoRequests(baseUrl, sID) // initially, there are no requests diff --git a/web/.gitignore b/web/.gitignore index f19b3f1f..1ccc1cda 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -6,3 +6,4 @@ ## etc *.log +/src/*-old diff --git a/web/src/db/tables/requests.ts b/web/src/db/tables/requests.ts index fa0bbefd..0dae27d7 100644 --- a/web/src/db/tables/requests.ts +++ b/web/src/db/tables/requests.ts @@ -7,6 +7,7 @@ export type Request = { method: string headers: Array<{ name: string; value: string }> url: URL + // requestPayload: Uint8Array // TODO: store request payload too? capturedAt: Date } diff --git a/web/src/db/tables/sessions.ts b/web/src/db/tables/sessions.ts index acca9e6c..b0be0607 100644 --- a/web/src/db/tables/sessions.ts +++ b/web/src/db/tables/sessions.ts @@ -3,6 +3,10 @@ import { Table } from 'dexie' export type Session = { sID: string humanReadableName: string + responseCode: number + responseHeaders: Array<{ name: string; value: string }> + responseDelay: number + responseBody: Uint8Array createdAt: Date } diff --git a/web/src/main.tsx b/web/src/main.tsx index fd6249ef..00bb784a 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -5,14 +5,14 @@ import { MantineProvider } from '@mantine/core' import { Notifications } from '@mantine/notifications' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' -import { Client } from './api' -import { Database } from './db' -import { createRoutes } from './routing' +import { Client } from '~/api' +import { Database } from '~/db' +import { createRoutes } from '~/routing' +import { BrowserNotificationsProvider, DataProvider, SettingsProvider } from './shared' import '@mantine/core/styles.css' import '@mantine/code-highlight/styles.css' import '@mantine/notifications/styles.css' import '~/theme/app.css' -import { BrowserNotificationsProvider, DataProvider, SessionsProvider, UISettingsProvider } from './shared' dayjs.extend(relativeTime) // https://day.js.org/docs/en/plugin/relative-time @@ -25,21 +25,17 @@ const App = (): React.JSX.Element => { - - - - - - - + + + + + ) } -const root = document.getElementById('root') as HTMLElement - -createRoot(root).render( +createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/web/src/routing/routing.tsx b/web/src/routing/routing.tsx index 6dce27be..6a45f4c5 100644 --- a/web/src/routing/routing.tsx +++ b/web/src/routing/routing.tsx @@ -18,7 +18,7 @@ export const createRoutes = (apiClient: Client): RouteObject[] => [ children: [ { index: true, - element: , + element: , id: RouteIDs.Home, }, { @@ -30,7 +30,7 @@ export const createRoutes = (apiClient: Client): RouteObject[] => [ // please note that `sID` and `rID` accessed via `useParams` hook, and changing this will break the app path: 's/:sID/:rID?', id: RouteIDs.SessionAndRequest, - element: , + element: , }, ], }, diff --git a/web/src/screens/components/header-new-session-modal.tsx b/web/src/screens/components/header-new-session-modal.tsx deleted file mode 100644 index f2908a6f..00000000 --- a/web/src/screens/components/header-new-session-modal.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import React, { useCallback, useState } from 'react' -import { Button, Checkbox, Group, Modal, NumberInput, Space, Text, Textarea, Title } from '@mantine/core' -import { IconCodeAsterisk, IconHeading, IconHourglassHigh, IconVersions } from '@tabler/icons-react' -import { useStorage, UsedStorageKeys } from '~/shared' - -const limits = { - statusCode: { min: 200, max: 530 }, - responseHeaders: { maxCount: 10, minNameLen: 1, maxNameLen: 40, maxValueLen: 2048 }, - delay: { min: 0, max: 30 }, -} - -export type NewSessionOptions = { - statusCode: number - headers: Array<{ name: string; value: string }> - delay: number - responseBody: string - destroyCurrentSession: boolean -} - -export default function HeaderNewSessionModal({ - opened, - loading = false, - onClose, - onCreate, - maxRequestBodySize = 10240, -}: { - opened: boolean - loading?: boolean - onClose: () => void - onCreate: (_: NewSessionOptions) => void - maxRequestBodySize: number | null -}): React.JSX.Element { - const [statusCode, setStatusCode] = useStorage(200, UsedStorageKeys.NewSessionStatusCode, 'session') - const [headersList, setHeadersList] = useStorage( - 'Content-Type: application/json\nServer: WebhookTester', - UsedStorageKeys.NewSessionHeadersList, - 'session' - ) - const [delay, setDelay] = useStorage(0, UsedStorageKeys.NewSessionSessionDelay, 'session') - const [responseBody, setResponseBody] = useStorage( - '{"captured": true}', - UsedStorageKeys.NewSessionResponseBody, - 'session' - ) - const [destroyCurrentSession, setDestroyCurrentSession] = useStorage( - true, - UsedStorageKeys.NewSessionDestroyCurrentSession, - 'session' - ) - - const [wrongStatusCode, setWrongStatusCode] = useState(false) - const [wrongDelay, setWrongDelay] = useState(false) - const [wrongResponseBody, setWrongResponseBody] = useState(false) - - /** Handle the creation of a new session */ - const handleCreate = useCallback(() => { - // validate all the fields - if (statusCode < limits.statusCode.min || statusCode > limits.statusCode.max) { - setWrongStatusCode(true) - - return - } else { - setWrongStatusCode(false) - } - - const headers = headersList - .split('\n') // split by each line - .map((line) => { - const [name, ...valueParts] = line.split(': ') - const value = valueParts.join(': ') // join in case of additional colons in value - - return { name: name.trim(), value: value.trim() } - }) - .filter((header) => header.name && header.value) // remove empty headers - .filter((header) => header.name.length >= limits.responseHeaders.minNameLen) // filter by min name length - .filter((header) => header.name.length <= limits.responseHeaders.maxNameLen) // filter by max name length - .filter((header) => header.value.length <= limits.responseHeaders.maxValueLen) // filter by max value length - .slice(0, limits.responseHeaders.maxCount) - - if (delay < limits.delay.min || delay > limits.delay.max) { - setWrongDelay(true) - - return - } else { - setWrongDelay(false) - } - - if (!!maxRequestBodySize && maxRequestBodySize > 0 && responseBody.length > maxRequestBodySize) { - setWrongResponseBody(true) - - return - } else { - setWrongResponseBody(false) - } - - onCreate({ statusCode, headers, delay, responseBody, destroyCurrentSession }) - }, [delay, destroyCurrentSession, headersList, maxRequestBodySize, onCreate, responseBody, statusCode]) - - return ( - Configure URL} - centered - > - - You have the ability to customize how your URL will respond by changing the status code, headers, response delay - and the content. - - - } - min={limits.statusCode.min} - max={limits.statusCode.max} - error={wrongStatusCode} - disabled={loading} - value={statusCode} - onChange={(v: string | number): void => setStatusCode(typeof v === 'string' ? parseInt(v, 10) : v)} - /> -