diff --git a/cmd/main.go b/cmd/main.go index 0f15087..703a661 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -87,6 +87,9 @@ func serve(ctx context.Context) error { cdr := handler.Cdr{Service: &service.Cdr{Cursor: pool}} r.Mount("/cdr", cdr.Router()) + location := handler.Location{Service: &service.Location{Cursor: pool}} + r.Mount("/locations", location.Router()) + r.Mount("/metrics", promhttp.Handler()) listen := os.Getenv("LISTEN_ADDR") diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 3f208e9..be1953c 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -118,6 +118,28 @@ definitions: sid: type: integer type: object + model.Location: + properties: + address: + type: string + endpoint: + type: string + id: + type: string + user_agent: + type: string + type: object + model.LocationPage: + properties: + locations: + items: + $ref: '#/definitions/model.Location' + type: array + retrieved: + type: integer + total: + type: integer + type: object model.NewEndpoint: properties: accountCode: @@ -376,4 +398,27 @@ paths: summary: Update the specified endpoint. Omitted or null fields will remain unchanged. tags: - endpoints + /locations: + get: + parameters: + - default: 0 + description: Zero based page to fetch + in: query + name: page + type: integer + - default: 20 + description: Max amount of results to be returned + in: query + name: pageSize + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.LocationPage' + summary: List endpoint locations. This is the same as PJSIP contacts. + tags: + - location swagger: "2.0" diff --git a/internal/handler/location.go b/internal/handler/location.go new file mode 100644 index 0000000..af17c40 --- /dev/null +++ b/internal/handler/location.go @@ -0,0 +1,62 @@ +package handler + +import ( + "encoding/json" + "github.com/crazybolillo/eryth/internal/query" + "github.com/crazybolillo/eryth/internal/service" + "github.com/go-chi/chi/v5" + "log/slog" + "net/http" +) + +type Location struct { + Service *service.Location +} + +func (l *Location) Router() chi.Router { + r := chi.NewRouter() + r.Get("/", l.list) + + return r +} + +// @Summary List endpoint locations. This is the same as PJSIP contacts. +// @Param page query int false "Zero based page to fetch" default(0) +// @Param pageSize query int false "Max amount of results to be returned" default(20) +// @Produce json +// @Success 200 {object} model.LocationPage +// @Tags location +// @Router /locations [get] +func (l *Location) list(w http.ResponseWriter, r *http.Request) { + page, err := query.GetIntOr(r.URL.Query(), "page", 0) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + pageSize, err := query.GetIntOr(r.URL.Query(), "pageSize", 25) + if err != nil || page < 0 || pageSize < 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + + res, err := l.Service.Paginate(r.Context(), page, pageSize) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + slog.Error("Failed to list locations", "path", r.URL.Path, "reason", err) + return + } + + content, err := json.Marshal(res) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + slog.Error("Failed to marshall response", "path", r.URL.Path, "reason", err) + return + } + + w.Header().Set("Content-Type", "application/json") + _, err = w.Write(content) + if err != nil { + slog.Error("Failed to write response", "path", r.URL.Path, "reason", err) + } +} diff --git a/internal/service/location.go b/internal/service/location.go new file mode 100644 index 0000000..4801bc1 --- /dev/null +++ b/internal/service/location.go @@ -0,0 +1,83 @@ +package service + +import ( + "context" + "errors" + "github.com/crazybolillo/eryth/internal/sqlc" + "github.com/crazybolillo/eryth/pkg/model" + "github.com/jackc/pgx/v5" + "net/url" + "strings" +) + +type Location struct { + Cursor Cursor `json:"cursor"` +} + +func locationParseRow(row sqlc.ListLocationsRow) model.Location { + location := model.Location{ + Endpoint: row.Endpoint.String, + UserAgent: row.UserAgent.String, + } + + id := strings.Split(row.ID, "@") + if len(id) != 2 { + location.ID = id[0] + } else { + location.ID = id[1] + } + + var ua string + rawUA := strings.Replace(row.UserAgent.String, "^", "%", -1) + ua, err := url.QueryUnescape(rawUA) + if err != nil { + ua = rawUA + } + location.UserAgent = ua + + var uri string + rawUri := strings.Replace(row.Uri.String, "^", "%", -1) + uri, err = url.QueryUnescape(rawUri) + if err != nil { + uri = rawUri + } + + addr := strings.Split(uri, "@") + if len(addr) != 2 { + location.Address = addr[0] + } else { + location.Address = addr[1] + } + + return location +} + +func (l *Location) Paginate(ctx context.Context, page, size int) (model.LocationPage, error) { + queries := sqlc.New(l.Cursor) + + rows, err := queries.ListLocations(ctx, sqlc.ListLocationsParams{ + Limit: int32(size), + Offset: int32(page), + }) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return model.LocationPage{}, err + } + + count, err := queries.CountLocations(ctx) + if err != nil { + return model.LocationPage{}, err + } + + locations := make([]model.Location, len(rows)) + for idx, row := range rows { + locations[idx] = locationParseRow(row) + } + + res := model.LocationPage{ + Locations: locations, + Total: count, + Retrieved: len(locations), + } + + return res, nil +} diff --git a/internal/service/location_test.go b/internal/service/location_test.go new file mode 100644 index 0000000..b4d955b --- /dev/null +++ b/internal/service/location_test.go @@ -0,0 +1,42 @@ +package service + +import ( + "github.com/crazybolillo/eryth/internal/db" + "github.com/crazybolillo/eryth/internal/sqlc" + "github.com/crazybolillo/eryth/pkg/model" + "reflect" + "testing" +) + +func TestLocationParseRow(t *testing.T) { + cases := []struct { + name string + row sqlc.ListLocationsRow + want model.Location + }{ + { + name: "escaped", + row: sqlc.ListLocationsRow{ + ID: "rando", + Endpoint: db.Text("kiwi"), + UserAgent: db.Text("LinphoneAndroid/5.2.5 (Galaxy S7 edge) LinphoneSDK/5.3.47 (tags/5.3.47^5E0)"), + Uri: db.Text("sip:kiwi@192.168.100.24:45331^3Btransport=tcp"), + }, + want: model.Location{ + ID: "rando", + Endpoint: "kiwi", + UserAgent: "LinphoneAndroid/5.2.5 (Galaxy S7 edge) LinphoneSDK/5.3.47 (tags/5.3.47^0)", + Address: "192.168.100.24:45331;transport=tcp", + }, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + got := locationParseRow(tt.row) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/sqlc/queries.sql.go b/internal/sqlc/queries.sql.go index 35092c5..e3db26b 100644 --- a/internal/sqlc/queries.sql.go +++ b/internal/sqlc/queries.sql.go @@ -59,6 +59,17 @@ func (q *Queries) CountEndpoints(ctx context.Context) (int64, error) { return count, err } +const countLocations = `-- name: CountLocations :one +SELECT COUNT(*) FROM ps_contacts +` + +func (q *Queries) CountLocations(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, countLocations) + var count int64 + err := row.Scan(&count) + return count, err +} + const deleteAOR = `-- name: DeleteAOR :exec DELETE FROM ps_aors WHERE id = $1 ` @@ -346,6 +357,54 @@ func (q *Queries) ListEndpoints(ctx context.Context, arg ListEndpointsParams) ([ return items, nil } +const listLocations = `-- name: ListLocations :many +SELECT + id, + endpoint, + user_agent, + uri +FROM + ps_contacts +LIMIT $1 OFFSET $2 +` + +type ListLocationsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type ListLocationsRow struct { + ID string `json:"id"` + Endpoint pgtype.Text `json:"endpoint"` + UserAgent pgtype.Text `json:"user_agent"` + Uri pgtype.Text `json:"uri"` +} + +func (q *Queries) ListLocations(ctx context.Context, arg ListLocationsParams) ([]ListLocationsRow, error) { + rows, err := q.db.Query(ctx, listLocations, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListLocationsRow + for rows.Next() { + var i ListLocationsRow + if err := rows.Scan( + &i.ID, + &i.Endpoint, + &i.UserAgent, + &i.Uri, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const newAOR = `-- name: NewAOR :exec INSERT INTO ps_aors (id, max_contacts) diff --git a/pkg/model/location.go b/pkg/model/location.go new file mode 100644 index 0000000..581d78d --- /dev/null +++ b/pkg/model/location.go @@ -0,0 +1,14 @@ +package model + +type Location struct { + ID string `json:"id"` + Endpoint string `json:"endpoint"` + Address string `json:"address"` + UserAgent string `json:"user_agent"` +} + +type LocationPage struct { + Total int64 `json:"total"` + Retrieved int `json:"retrieved"` + Locations []Location `json:"locations"` +} diff --git a/queries.sql b/queries.sql index 2b8e4ab..d930cdf 100644 --- a/queries.sql +++ b/queries.sql @@ -181,3 +181,16 @@ LIMIT $1 OFFSET $2; -- name: CountCallRecords :one SELECT COUNT(*) FROM cdr; + +-- name: ListLocations :many +SELECT + id, + endpoint, + user_agent, + uri +FROM + ps_contacts +LIMIT $1 OFFSET $2; + +-- name: CountLocations :one +SELECT COUNT(*) FROM ps_contacts;