Mike 2 лет назад
Родитель
Сommit
20e0d13dd3
7 измененных файлов с 272 добавлено и 56 удалено
  1. 3 0
      .mockery.yaml
  2. 12 4
      README.md
  3. 30 4
      api.yml
  4. 1 1
      cmd/server/main.go
  5. 66 30
      server/http/mgmt_api.go
  6. 78 17
      server/http/mgmt_api_test.go
  7. 82 0
      server/http/mock_session_retriever_test.go

+ 3 - 0
.mockery.yaml

@@ -22,6 +22,9 @@ packages:
       UserManager:
         config:
           filename: "mock_user_manager_test.go"
+      SessionRetriever:
+        config:
+          filename: "mock_session_retriever_test.go"
   github.com/mk6i/retro-aim-server/server/oscar/handler:
     interfaces:
       ResponseWriter:

+ 12 - 4
README.md

@@ -34,22 +34,30 @@ This project is under active development. Contributions are welcome!
 
 Follow [this guide](./docs/BUILD.md) to learn how to compile and run Retro AIM Server.
 
-## 👤 User Management
+## 👤 Management API
 
-Accounts can be added via the User Management API (see [OpenAPI spec](./api.yml)):
+The Management API provides functionality for administering the server (see [OpenAPI spec](./api.yml)):
 
 ### List Users
 
-```curl
+```shell
 curl http://localhost:8080/user
 ```
 
 ### Create Users
 
-```curl
+```shell
 curl -d'{"screen_name":"myScreenName", "password":"thepassword"}' http://localhost:8080/user
 ```
 
+### List Active Sessions
+
+This request lists sessions for all logged in users.
+
+```shell
+curl http://localhost:8080/session
+```
+
 ## 🔗 Acknowledgements
 
 - [aim-oscar-server](https://github.com/ox/aim-oscar-server) is another cool open source AIM server project.

+ 30 - 4
api.yml

@@ -31,6 +31,19 @@ paths:
           description: User account created successfully.
         '400':
           description: Bad request. Invalid input data.
+
+  /session:
+    get:
+      summary: Get active sessions
+      description: Retrieve a list of active sessions of logged in users.
+      responses:
+        '200':
+          description: Successful response containing a list of active sessions.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/SessionResponse'
+
 components:
   schemas:
     User:
@@ -39,8 +52,21 @@ components:
         screen_name:
           type: string
           description: The user's screen name.
-        password:
+
+    SessionResponse:
+      type: object
+      properties:
+        count:
+          type: integer
+          description: The number of active sessions.
+        sessions:
+          type: array
+          items:
+            $ref: '#/components/schemas/Session'
+
+    Session:
+      type: object
+      properties:
+        screen_name:
           type: string
-          description: The user's password.
-          readOnly: true
-          format: password
+          description: The screen name associated with the session.

+ 1 - 1
cmd/server/main.go

@@ -39,7 +39,7 @@ func main() {
 	wg.Add(4)
 
 	go func() {
-		http.StartManagementAPI(feedbagStore, logger)
+		http.StartManagementAPI(feedbagStore, sessionManager, logger)
 		wg.Done()
 	}()
 	go func(logger *slog.Logger) {

+ 66 - 30
server/http/mgmt_api.go

@@ -7,24 +7,43 @@ import (
 	"net/http"
 	"os"
 
+	"github.com/google/uuid"
+
 	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/state"
-
-	"github.com/google/uuid"
 )
 
+type createUser struct {
+	state.User
+	Password string `json:"password,omitempty"`
+}
+
+type userSession struct {
+	ScreenName string `json:"screen_name"`
+}
+
+type onlineUsers struct {
+	Count    int           `json:"count"`
+	Sessions []userSession `json:"sessions"`
+}
+
 type UserManager interface {
 	AllUsers() ([]state.User, error)
 	InsertUser(u state.User) error
 }
 
-func StartManagementAPI(userManager UserManager, logger *slog.Logger) {
-	uh := userHandler{
-		UserManager: userManager,
-		logger:      logger,
-	}
+type SessionRetriever interface {
+	AllSessions() []*state.Session
+}
+
+func StartManagementAPI(userManager UserManager, sessionRetriever SessionRetriever, logger *slog.Logger) {
 	mux := http.NewServeMux()
-	mux.HandleFunc("/user", uh.ServeHTTP)
+	mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
+		userHandler(w, r, userManager, logger)
+	})
+	mux.HandleFunc("/session", func(w http.ResponseWriter, r *http.Request) {
+		sessionHandler(w, r, sessionRetriever)
+	})
 
 	//todo make port configurable
 	addr := config.Address("", 8080)
@@ -35,28 +54,50 @@ func StartManagementAPI(userManager UserManager, logger *slog.Logger) {
 	}
 }
 
-type userHandler struct {
-	UserManager
-	logger *slog.Logger
-}
-
-func (uh userHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+func userHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, logger *slog.Logger) {
 	switch r.Method {
 	case http.MethodGet:
-		uh.getUsers(w, r)
+		getUserHandler(w, r, userManager, logger)
 	case http.MethodPost:
-		uh.createUser(w, r)
+		postUserHandler(w, r, userManager, logger)
 	default:
 		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
 	}
 }
 
-// getUsers handles the GET /user endpoint.
-func (uh userHandler) getUsers(w http.ResponseWriter, _ *http.Request) {
+// sessionHandler handles GET /session
+func sessionHandler(w http.ResponseWriter, r *http.Request, sessionRetriever SessionRetriever) {
+	w.Header().Set("Content-Type", "application/json")
+	if r.Method != http.MethodGet {
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+
+	allUsers := sessionRetriever.AllSessions()
+
+	ou := onlineUsers{
+		Count:    len(allUsers),
+		Sessions: make([]userSession, 0),
+	}
+
+	for _, s := range allUsers {
+		ou.Sessions = append(ou.Sessions, userSession{
+			ScreenName: s.ScreenName(),
+		})
+	}
+
+	if err := json.NewEncoder(w).Encode(ou); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+// getUserHandler handles the GET /user endpoint.
+func getUserHandler(w http.ResponseWriter, _ *http.Request, userManager UserManager, logger *slog.Logger) {
 	w.Header().Set("Content-Type", "application/json")
-	users, err := uh.AllUsers()
+	users, err := userManager.AllUsers()
 	if err != nil {
-		uh.logger.Error("error in GET /user", "err", err.Error())
+		logger.Error("error in GET /user", "err", err.Error())
 		http.Error(w, "internal server error", http.StatusInternalServerError)
 		return
 	}
@@ -66,14 +107,9 @@ func (uh userHandler) getUsers(w http.ResponseWriter, _ *http.Request) {
 	}
 }
 
-type CreateUser struct {
-	state.User
-	Password string `json:"password,omitempty"`
-}
-
-// createUser handles the POST /user endpoint.
-func (uh userHandler) createUser(w http.ResponseWriter, r *http.Request) {
-	var newUser CreateUser
+// postUserHandler handles the POST /user endpoint.
+func postUserHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, logger *slog.Logger) {
+	var newUser createUser
 	if err := json.NewDecoder(r.Body).Decode(&newUser); err != nil {
 		http.Error(w, "malformed input", http.StatusBadRequest)
 		return
@@ -81,8 +117,8 @@ func (uh userHandler) createUser(w http.ResponseWriter, r *http.Request) {
 	newUser.AuthKey = uuid.New().String()
 	// todo does the request contain authkey?
 	newUser.HashPassword(newUser.Password)
-	if err := uh.InsertUser(newUser.User); err != nil {
-		uh.logger.Error("error in GET /user", "err", err.Error())
+	if err := userManager.InsertUser(newUser.User); err != nil {
+		logger.Error("error in GET /user", "err", err.Error())
 		http.Error(w, "internal server error", http.StatusInternalServerError)
 		return
 	}

+ 78 - 17
server/http/mgmt_api_test.go

@@ -13,6 +13,76 @@ import (
 	"github.com/stretchr/testify/mock"
 )
 
+func TestSessionHandler_GET(t *testing.T) {
+	fnNewSess := func(screenName string) *state.Session {
+		sess := state.NewSession()
+		sess.SetScreenName(screenName)
+		return sess
+	}
+	tt := []struct {
+		name           string
+		sessions       []*state.Session
+		userHandlerErr error
+		want           string
+		statusCode     int
+	}{
+		{
+			name:       "without sessions",
+			sessions:   []*state.Session{},
+			want:       `{"count":0,"sessions":[]}`,
+			statusCode: http.StatusOK,
+		},
+		{
+			name: "with sessions",
+			sessions: []*state.Session{
+				fnNewSess("userA"),
+				fnNewSess("userB"),
+			},
+			want:       `{"count":2,"sessions":[{"screen_name":"userA"},{"screen_name":"userB"}]}`,
+			statusCode: http.StatusOK,
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodGet, "/session", nil)
+			responseRecorder := httptest.NewRecorder()
+
+			sessionRetriever := newMockSessionRetriever(t)
+			sessionRetriever.EXPECT().
+				AllSessions().
+				Return(tc.sessions)
+
+			sessionHandler(responseRecorder, request, sessionRetriever)
+
+			if responseRecorder.Code != tc.statusCode {
+				t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code)
+			}
+
+			if strings.TrimSpace(responseRecorder.Body.String()) != tc.want {
+				t.Errorf("Want '%s', got '%s'", tc.want, responseRecorder.Body)
+			}
+		})
+	}
+}
+
+func TestSessionHandler_DisallowedMethod(t *testing.T) {
+	request := httptest.NewRequest(http.MethodPut, "/session", nil)
+	responseRecorder := httptest.NewRecorder()
+
+	sessionHandler(responseRecorder, request, nil)
+
+	wantCode := http.StatusMethodNotAllowed
+	if responseRecorder.Code != wantCode {
+		t.Errorf("want status '%d', got '%d'", http.StatusMethodNotAllowed, responseRecorder.Code)
+	}
+
+	wantBody := `method not allowed`
+	if strings.TrimSpace(responseRecorder.Body.String()) != wantBody {
+		t.Errorf("want '%s', got '%s'", wantBody, responseRecorder.Body)
+	}
+}
+
 func TestUserHandler_GET(t *testing.T) {
 	tt := []struct {
 		name           string
@@ -22,13 +92,13 @@ func TestUserHandler_GET(t *testing.T) {
 		statusCode     int
 	}{
 		{
-			name:       "without users",
+			name:       "empty user store",
 			users:      []state.User{},
 			want:       `[]`,
 			statusCode: http.StatusOK,
 		},
 		{
-			name: "with users",
+			name: "user store containing 2 users",
 			users: []state.User{
 				{ScreenName: "userA"},
 				{ScreenName: "userB"},
@@ -47,7 +117,7 @@ func TestUserHandler_GET(t *testing.T) {
 
 	for _, tc := range tt {
 		t.Run(tc.name, func(t *testing.T) {
-			request := httptest.NewRequest(http.MethodGet, "/users", nil)
+			request := httptest.NewRequest(http.MethodGet, "/user", nil)
 			responseRecorder := httptest.NewRecorder()
 
 			userManager := newMockUserManager(t)
@@ -55,11 +125,7 @@ func TestUserHandler_GET(t *testing.T) {
 				AllUsers().
 				Return(tc.users, tc.userHandlerErr)
 
-			userHandler := userHandler{
-				UserManager: userManager,
-				logger:      slog.Default(),
-			}
-			userHandler.ServeHTTP(responseRecorder, request)
+			userHandler(responseRecorder, request, userManager, slog.Default())
 
 			if responseRecorder.Code != tc.statusCode {
 				t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code)
@@ -113,7 +179,7 @@ func TestUserHandler_POST(t *testing.T) {
 
 	for _, tc := range tt {
 		t.Run(tc.name, func(t *testing.T) {
-			request := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(tc.body))
+			request := httptest.NewRequest(http.MethodPost, "/user", strings.NewReader(tc.body))
 			responseRecorder := httptest.NewRecorder()
 
 			userManager := newMockUserManager(t)
@@ -122,11 +188,7 @@ func TestUserHandler_POST(t *testing.T) {
 				Return(tc.userHandlerErr).
 				Maybe()
 
-			userHandler := userHandler{
-				UserManager: userManager,
-				logger:      slog.Default(),
-			}
-			userHandler.ServeHTTP(responseRecorder, request)
+			userHandler(responseRecorder, request, userManager, slog.Default())
 
 			if responseRecorder.Code != tc.statusCode {
 				t.Errorf("want status '%d', got '%d'", tc.statusCode, responseRecorder.Code)
@@ -140,11 +202,10 @@ func TestUserHandler_POST(t *testing.T) {
 }
 
 func TestUserHandler_DisallowedMethod(t *testing.T) {
-	request := httptest.NewRequest(http.MethodPut, "/users", nil)
+	request := httptest.NewRequest(http.MethodPut, "/user", nil)
 	responseRecorder := httptest.NewRecorder()
 
-	userHandler := userHandler{}
-	userHandler.ServeHTTP(responseRecorder, request)
+	userHandler(responseRecorder, request, nil, nil)
 
 	wantCode := http.StatusMethodNotAllowed
 	if responseRecorder.Code != wantCode {

+ 82 - 0
server/http/mock_session_retriever_test.go

@@ -0,0 +1,82 @@
+// Code generated by mockery v2.40.1. DO NOT EDIT.
+
+package http
+
+import (
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockSessionRetriever is an autogenerated mock type for the SessionRetriever type
+type mockSessionRetriever struct {
+	mock.Mock
+}
+
+type mockSessionRetriever_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockSessionRetriever) EXPECT() *mockSessionRetriever_Expecter {
+	return &mockSessionRetriever_Expecter{mock: &_m.Mock}
+}
+
+// AllSessions provides a mock function with given fields:
+func (_m *mockSessionRetriever) AllSessions() []*state.Session {
+	ret := _m.Called()
+
+	if len(ret) == 0 {
+		panic("no return value specified for AllSessions")
+	}
+
+	var r0 []*state.Session
+	if rf, ok := ret.Get(0).(func() []*state.Session); ok {
+		r0 = rf()
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]*state.Session)
+		}
+	}
+
+	return r0
+}
+
+// mockSessionRetriever_AllSessions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AllSessions'
+type mockSessionRetriever_AllSessions_Call struct {
+	*mock.Call
+}
+
+// AllSessions is a helper method to define mock.On call
+func (_e *mockSessionRetriever_Expecter) AllSessions() *mockSessionRetriever_AllSessions_Call {
+	return &mockSessionRetriever_AllSessions_Call{Call: _e.mock.On("AllSessions")}
+}
+
+func (_c *mockSessionRetriever_AllSessions_Call) Run(run func()) *mockSessionRetriever_AllSessions_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run()
+	})
+	return _c
+}
+
+func (_c *mockSessionRetriever_AllSessions_Call) Return(_a0 []*state.Session) *mockSessionRetriever_AllSessions_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockSessionRetriever_AllSessions_Call) RunAndReturn(run func() []*state.Session) *mockSessionRetriever_AllSessions_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockSessionRetriever creates a new instance of mockSessionRetriever. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockSessionRetriever(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockSessionRetriever {
+	mock := &mockSessionRetriever{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}