From 838c4cc72d3784eed4f991438e66122c455678f4 Mon Sep 17 00:00:00 2001 From: CrazyBolillo Date: Fri, 16 Aug 2024 12:37:19 -0600 Subject: [PATCH 1/6] feat(endpoint): get single endpoint All data about a single endpoint may now be retrieved, to support this feature sids are provided when listing endpoints. --- cmd/main.go | 7 ++- docs/swagger.yaml | 47 ++++++++++++++++++++- internal/handler/endpoint.go | 82 ++++++++++++++++++++++++++++++++++-- internal/sqlc/queries.sql.go | 42 +++++++++++++++++- queries.sql | 14 +++++- 5 files changed, 184 insertions(+), 8 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 01c885c..5be8fa2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -81,5 +81,10 @@ func serve(ctx context.Context) error { r.Mount("/bouncer", authorization.Router()) slog.Info("Listening on :8080") - return http.ListenAndServe(":8080", r) + err = http.ListenAndServe(":8080", r) + if err != nil { + slog.Error("Failed to start server", "reason", err.Error()) + } + + return err } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index a25214a..6fcb79a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -38,6 +38,25 @@ definitions: transport: type: string type: object + handler.getEndpointResponse: + properties: + codecs: + items: + type: string + type: array + context: + type: string + displayName: + type: string + extension: + type: string + id: + type: string + maxContacts: + type: integer + transport: + type: string + type: object handler.listEndpointEntry: properties: context: @@ -48,8 +67,10 @@ definitions: type: string id: type: string + sid: + type: integer type: object - handler.listEndpointsRequest: + handler.listEndpointsResponse: properties: endpoints: items: @@ -103,7 +124,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/handler.listEndpointsRequest' + $ref: '#/definitions/handler.listEndpointsResponse' "400": description: Bad Request "500": @@ -149,4 +170,26 @@ paths: summary: Delete an endpoint and its associated resources. tags: - endpoints + /endpoints/{sid}: + get: + parameters: + - description: Requested endpoint's sid + in: path + name: sid + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.getEndpointResponse' + "400": + description: Bad Request + "500": + description: Internal Server Error + summary: Get information from a specific endpoint. + tags: + - endpoints swagger: "2.0" diff --git a/internal/handler/endpoint.go b/internal/handler/endpoint.go index 42939d4..e21a2b4 100644 --- a/internal/handler/endpoint.go +++ b/internal/handler/endpoint.go @@ -4,6 +4,7 @@ import ( "crypto/md5" "encoding/hex" "encoding/json" + "errors" "fmt" "github.com/crazybolillo/eryth/internal/db" "github.com/crazybolillo/eryth/internal/sqlc" @@ -32,20 +33,32 @@ type createEndpointRequest struct { } type listEndpointEntry struct { + Sid int32 `json:"sid"` ID string `json:"id"` Extension string `json:"extension"` Context string `json:"context"` DisplayName string `json:"displayName"` } -type listEndpointsRequest struct { +type listEndpointsResponse struct { Endpoints []listEndpointEntry `json:"endpoints"` } +type getEndpointResponse struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Transport string `json:"transport"` + Context string `json:"context"` + Codecs []string `json:"codecs"` + MaxContacts int32 `json:"maxContacts"` + Extension string `json:"extension"` +} + func (e *Endpoint) Router() chi.Router { r := chi.NewRouter() r.Post("/", e.create) r.Get("/", e.list) + r.Get("/{sid}", e.get) r.Delete("/{id}", e.delete) return r @@ -72,10 +85,72 @@ func displayNameFromClid(callerID string) string { return callerID[1:end] } +// @Summary Get information from a specific endpoint. +// @Param sid path int true "Requested endpoint's sid" +// @Produce json +// @Success 200 {object} getEndpointResponse +// @Failure 400 +// @Failure 500 +// @Tags endpoints +// @Router /endpoints/{sid} [get] +func (e *Endpoint) get(w http.ResponseWriter, r *http.Request) { + sid := chi.URLParam(r, "sid") + if sid == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + id, err := strconv.ParseInt(sid, 10, 32) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + tx, err := e.Begin(r.Context()) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + queries := sqlc.New(tx) + + row, err := queries.GetEndpointByID(r.Context(), int32(id)) + if errors.Is(err, pgx.ErrNoRows) { + w.WriteHeader(http.StatusNotFound) + return + } else if err != nil { + w.WriteHeader(http.StatusInternalServerError) + slog.Error("Failed to retrieve endpoint", slog.String("path", r.URL.Path), slog.String("reason", err.Error())) + return + } + + endpoint := getEndpointResponse{ + ID: row.ID, + Transport: row.Transport.String, + Context: row.Context.String, + Codecs: strings.Split(row.Allow.String, ","), + MaxContacts: row.MaxContacts.Int32, + Extension: row.Extension.String, + DisplayName: displayNameFromClid(row.Callerid.String), + } + content, err := json.Marshal(endpoint) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + slog.Error("Failed to marshall response", slog.String("path", r.URL.Path)) + return + } + + w.Header().Set("Content-Type", "application/json") + _, err = w.Write(content) + if err != nil { + slog.Error("Failed to write response", slog.String("path", r.URL.Path), slog.String("reason", err.Error())) + } +} + // @Summary List existing endpoints. // @Param limit query int false "Limit the amount of endpoints returned" default(15) // @Produce json -// @Success 200 {object} listEndpointsRequest +// @Success 200 {object} listEndpointsResponse // @Failure 400 // @Failure 500 // @Tags endpoints @@ -107,13 +182,14 @@ func (e *Endpoint) list(w http.ResponseWriter, r *http.Request) { for idx := range len(rows) { row := rows[idx] endpoints[idx] = listEndpointEntry{ + Sid: row.Sid, ID: row.ID, Extension: row.Extension.String, Context: row.Context.String, DisplayName: displayNameFromClid(row.Callerid.String), } } - response := listEndpointsRequest{ + response := listEndpointsResponse{ Endpoints: endpoints, } content, err := json.Marshal(response) diff --git a/internal/sqlc/queries.sql.go b/internal/sqlc/queries.sql.go index 697dd44..96b7dee 100644 --- a/internal/sqlc/queries.sql.go +++ b/internal/sqlc/queries.sql.go @@ -68,9 +68,47 @@ func (q *Queries) GetEndpointByExtension(ctx context.Context, arg GetEndpointByE return i, err } +const getEndpointByID = `-- name: GetEndpointByID :one +SELECT + pe.id, pe.callerid, pe.context, ee.extension, pe.transport, aor.max_contacts, pe.allow +FROM + ps_endpoints pe +INNER JOIN + ery_extension ee ON ee.endpoint_id = pe.sid +INNER JOIN + ps_aors aor ON aor.id = pe.id +WHERE + pe.sid = $1 +` + +type GetEndpointByIDRow struct { + ID string `json:"id"` + Callerid pgtype.Text `json:"callerid"` + Context pgtype.Text `json:"context"` + Extension pgtype.Text `json:"extension"` + Transport pgtype.Text `json:"transport"` + MaxContacts pgtype.Int4 `json:"max_contacts"` + Allow pgtype.Text `json:"allow"` +} + +func (q *Queries) GetEndpointByID(ctx context.Context, sid int32) (GetEndpointByIDRow, error) { + row := q.db.QueryRow(ctx, getEndpointByID, sid) + var i GetEndpointByIDRow + err := row.Scan( + &i.ID, + &i.Callerid, + &i.Context, + &i.Extension, + &i.Transport, + &i.MaxContacts, + &i.Allow, + ) + return i, err +} + const listEndpoints = `-- name: ListEndpoints :many SELECT - pe.id, pe.callerid, pe.context, ee.extension + pe.sid, pe.id, pe.callerid, pe.context, ee.extension FROM ps_endpoints pe LEFT JOIN @@ -80,6 +118,7 @@ LIMIT $1 ` type ListEndpointsRow struct { + Sid int32 `json:"sid"` ID string `json:"id"` Callerid pgtype.Text `json:"callerid"` Context pgtype.Text `json:"context"` @@ -96,6 +135,7 @@ func (q *Queries) ListEndpoints(ctx context.Context, limit int32) ([]ListEndpoin for rows.Next() { var i ListEndpointsRow if err := rows.Scan( + &i.Sid, &i.ID, &i.Callerid, &i.Context, diff --git a/queries.sql b/queries.sql index 387d23a..ce03af5 100644 --- a/queries.sql +++ b/queries.sql @@ -28,7 +28,7 @@ DELETE FROM ps_auths WHERE id = $1; -- name: ListEndpoints :many SELECT - pe.id, pe.callerid, pe.context, ee.extension + pe.sid, pe.id, pe.callerid, pe.context, ee.extension FROM ps_endpoints pe LEFT JOIN @@ -53,3 +53,15 @@ INNER JOIN ps_endpoints src ON src.id = $1 WHERE ee.extension = $2; + +-- name: GetEndpointByID :one +SELECT + pe.id, pe.callerid, pe.context, ee.extension, pe.transport, aor.max_contacts, pe.allow +FROM + ps_endpoints pe +INNER JOIN + ery_extension ee ON ee.endpoint_id = pe.sid +INNER JOIN + ps_aors aor ON aor.id = pe.id +WHERE + pe.sid = $1; \ No newline at end of file From f36113cfc6297768828e9a2eca0400b39e529ff6 Mon Sep 17 00:00:00 2001 From: CrazyBolillo Date: Fri, 16 Aug 2024 14:39:51 -0600 Subject: [PATCH 2/6] feat(endpoint): support pagination --- docs/swagger.yaml | 15 +++++++++++--- internal/handler/endpoint.go | 38 +++++++++++++++++++++++++----------- internal/query/query.go | 20 +++++++++++++++++++ internal/sqlc/queries.sql.go | 22 ++++++++++++++++++--- queries.sql | 7 +++++-- 5 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 internal/query/query.go diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6fcb79a..85584a1 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -76,6 +76,10 @@ definitions: items: $ref: '#/definitions/handler.listEndpointEntry' type: array + retrieved: + type: integer + total: + type: integer type: object host: localhost:8080 info: @@ -113,10 +117,15 @@ paths: /endpoints: get: parameters: - - default: 15 - description: Limit the amount of endpoints returned + - default: 0 + description: Zero based page to fetch + in: query + name: page + type: integer + - default: 10 + description: Max amount of results to be returned in: query - name: limit + name: pageSize type: integer produces: - application/json diff --git a/internal/handler/endpoint.go b/internal/handler/endpoint.go index e21a2b4..0847b7d 100644 --- a/internal/handler/endpoint.go +++ b/internal/handler/endpoint.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "github.com/crazybolillo/eryth/internal/db" + "github.com/crazybolillo/eryth/internal/query" "github.com/crazybolillo/eryth/internal/sqlc" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" @@ -41,6 +42,8 @@ type listEndpointEntry struct { } type listEndpointsResponse struct { + Total int64 `json:"total"` + Retrieved int `json:"retrieved"` Endpoints []listEndpointEntry `json:"endpoints"` } @@ -148,7 +151,8 @@ func (e *Endpoint) get(w http.ResponseWriter, r *http.Request) { } // @Summary List existing endpoints. -// @Param limit query int false "Limit the amount of endpoints returned" default(15) +// @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(10) // @Produce json // @Success 200 {object} listEndpointsResponse // @Failure 400 @@ -156,19 +160,23 @@ func (e *Endpoint) get(w http.ResponseWriter, r *http.Request) { // @Tags endpoints // @Router /endpoints [get] func (e *Endpoint) list(w http.ResponseWriter, r *http.Request) { - qlim := r.URL.Query().Get("limit") - limit := 15 - if qlim != "" { - conv, err := strconv.Atoi(qlim) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - limit = conv + 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", 10) + if err != nil || page < 0 || pageSize < 0 { + w.WriteHeader(http.StatusBadRequest) + return } queries := sqlc.New(e.Conn) - rows, err := queries.ListEndpoints(r.Context(), int32(limit)) + rows, err := queries.ListEndpoints(r.Context(), sqlc.ListEndpointsParams{ + Limit: int32(pageSize), + Offset: int32(page * pageSize), + }) if err != nil { slog.Error("Query execution failed", slog.String("path", r.URL.Path), slog.String("msg", err.Error())) w.WriteHeader(http.StatusInternalServerError) @@ -177,6 +185,12 @@ func (e *Endpoint) list(w http.ResponseWriter, r *http.Request) { if rows == nil { rows = []sqlc.ListEndpointsRow{} } + total, err := queries.CountEndpoints(r.Context()) + if err != nil { + slog.Error("Query execution failed", slog.String("path", r.URL.Path), slog.String("msg", err.Error())) + w.WriteHeader(http.StatusInternalServerError) + return + } endpoints := make([]listEndpointEntry, len(rows)) for idx := range len(rows) { @@ -190,6 +204,8 @@ func (e *Endpoint) list(w http.ResponseWriter, r *http.Request) { } } response := listEndpointsResponse{ + Total: total, + Retrieved: len(rows), Endpoints: endpoints, } content, err := json.Marshal(response) diff --git a/internal/query/query.go b/internal/query/query.go new file mode 100644 index 0000000..1897918 --- /dev/null +++ b/internal/query/query.go @@ -0,0 +1,20 @@ +package query + +import ( + "net/url" + "strconv" +) + +func GetIntOr(values url.Values, name string, defaultValue int) (int, error) { + value := values.Get(name) + if value == "" { + return defaultValue, nil + } + + intValue, err := strconv.Atoi(value) + if err != nil { + return defaultValue, err + } + + return intValue, nil +} diff --git a/internal/sqlc/queries.sql.go b/internal/sqlc/queries.sql.go index 96b7dee..95da9b7 100644 --- a/internal/sqlc/queries.sql.go +++ b/internal/sqlc/queries.sql.go @@ -11,6 +11,17 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countEndpoints = `-- name: CountEndpoints :one +SELECT COUNT(*) FROM ps_endpoints +` + +func (q *Queries) CountEndpoints(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, countEndpoints) + var count int64 + err := row.Scan(&count) + return count, err +} + const deleteAOR = `-- name: DeleteAOR :exec DELETE FROM ps_aors WHERE id = $1 ` @@ -114,9 +125,14 @@ FROM LEFT JOIN ery_extension ee ON ee.endpoint_id = pe.sid -LIMIT $1 +LIMIT $1 OFFSET $2 ` +type ListEndpointsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + type ListEndpointsRow struct { Sid int32 `json:"sid"` ID string `json:"id"` @@ -125,8 +141,8 @@ type ListEndpointsRow struct { Extension pgtype.Text `json:"extension"` } -func (q *Queries) ListEndpoints(ctx context.Context, limit int32) ([]ListEndpointsRow, error) { - rows, err := q.db.Query(ctx, listEndpoints, limit) +func (q *Queries) ListEndpoints(ctx context.Context, arg ListEndpointsParams) ([]ListEndpointsRow, error) { + rows, err := q.db.Query(ctx, listEndpoints, arg.Limit, arg.Offset) if err != nil { return nil, err } diff --git a/queries.sql b/queries.sql index ce03af5..44c9c68 100644 --- a/queries.sql +++ b/queries.sql @@ -34,7 +34,7 @@ FROM LEFT JOIN ery_extension ee ON ee.endpoint_id = pe.sid -LIMIT $1; +LIMIT $1 OFFSET $2; -- name: NewExtension :exec INSERT INTO ery_extension @@ -64,4 +64,7 @@ INNER JOIN INNER JOIN ps_aors aor ON aor.id = pe.id WHERE - pe.sid = $1; \ No newline at end of file + pe.sid = $1; + +-- name: CountEndpoints :one +SELECT COUNT(*) FROM ps_endpoints; From 9a9b285776f1b07642c3a1353106579912b5dfbd Mon Sep 17 00:00:00 2001 From: CrazyBolillo Date: Mon, 19 Aug 2024 15:17:00 -0600 Subject: [PATCH 3/6] feat(endpoint): return endpoint on creation Besides this being customary, it saves consumers a roundtrip if they want to verify the state of the created resource. --- docs/swagger.yaml | 8 +++++-- internal/handler/endpoint.go | 46 ++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 85584a1..11d0e48 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -54,6 +54,8 @@ definitions: type: string maxContacts: type: integer + sid: + type: integer transport: type: string type: object @@ -152,8 +154,10 @@ paths: schema: $ref: '#/definitions/handler.createEndpointRequest' responses: - "204": - description: No Content + "201": + description: Created + schema: + $ref: '#/definitions/handler.getEndpointResponse' "400": description: Bad Request "500": diff --git a/internal/handler/endpoint.go b/internal/handler/endpoint.go index 0847b7d..9cf0384 100644 --- a/internal/handler/endpoint.go +++ b/internal/handler/endpoint.go @@ -48,6 +48,7 @@ type listEndpointsResponse struct { } type getEndpointResponse struct { + Sid int32 `json:"sid"` ID string `json:"id"` DisplayName string `json:"displayName"` Transport string `json:"transport"` @@ -128,6 +129,7 @@ func (e *Endpoint) get(w http.ResponseWriter, r *http.Request) { } endpoint := getEndpointResponse{ + Sid: int32(id), ID: row.ID, Transport: row.Transport.String, Context: row.Context.String, @@ -225,7 +227,7 @@ func (e *Endpoint) list(w http.ResponseWriter, r *http.Request) { // @Summary Create a new endpoint. // @Accept json // @Param payload body createEndpointRequest true "Endpoint's information" -// @Success 204 +// @Success 201 {object} getEndpointResponse // @Failure 400 // @Failure 500 // @Tags endpoints @@ -302,7 +304,47 @@ func (e *Endpoint) create(w http.ResponseWriter, r *http.Request) { return } - w.WriteHeader(http.StatusNoContent) + // TODO: Duplicate code, same as when fetching endpoint. Probably should put this into a service layer. + tx, err = e.Begin(r.Context()) + queries = sqlc.New(tx) + if err != nil { + slog.Error("Failed to create new transaction", slog.String("path", r.URL.Path), slog.String("reason", err.Error())) + w.WriteHeader(http.StatusInternalServerError) + return + } + res, err := queries.GetEndpointByID(r.Context(), sid) + if err != nil { + slog.Error( + "Failed to retrieve created endpoint", + slog.String("path", r.URL.Path), slog.String("reason", err.Error()), slog.Int("sid", int(sid)), + ) + w.WriteHeader(http.StatusInternalServerError) + return + } + + endpoint := getEndpointResponse{ + Sid: sid, + ID: res.ID, + Transport: res.Transport.String, + Context: res.Context.String, + Codecs: strings.Split(res.Allow.String, ","), + MaxContacts: res.MaxContacts.Int32, + Extension: res.Extension.String, + DisplayName: displayNameFromClid(res.Callerid.String), + } + content, err := json.Marshal(endpoint) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + slog.Error("Failed to marshall response", slog.String("path", r.URL.Path)) + return + } + + w.Header().Set("Content-Type", "application/json") + _, err = w.Write(content) + if err != nil { + slog.Error("Failed to write response", slog.String("path", r.URL.Path), slog.String("reason", err.Error())) + } + w.WriteHeader(http.StatusCreated) } // @Summary Delete an endpoint and its associated resources. From be24330e61c06a9ab9ae9548af98ddd5b7da248b Mon Sep 17 00:00:00 2001 From: CrazyBolillo Date: Mon, 19 Aug 2024 17:20:51 -0600 Subject: [PATCH 4/6] fix(endpoints): delete extension on cascade Deleting was impossible after adding extension mapping support since foreign keys were referencing the endpoint. The database structure was updated to handle such cases and delete extensions on CASCADE. The implementation was also changed to use endpoint sid instead of ID. --- .../20240819195341_ery_extension_cascade.sql | 14 ++++++++++++++ docs/swagger.yaml | 9 ++++----- internal/handler/endpoint.go | 13 +++++++------ internal/sqlc/queries.sql.go | 12 +++++++----- queries.sql | 4 ++-- 5 files changed, 34 insertions(+), 18 deletions(-) create mode 100644 db/migrations/20240819195341_ery_extension_cascade.sql diff --git a/db/migrations/20240819195341_ery_extension_cascade.sql b/db/migrations/20240819195341_ery_extension_cascade.sql new file mode 100644 index 0000000..b5bface --- /dev/null +++ b/db/migrations/20240819195341_ery_extension_cascade.sql @@ -0,0 +1,14 @@ +-- migrate:up +ALTER TABLE ery_extension DROP CONSTRAINT ery_extension_endpoint_id_fkey; + +ALTER TABLE + ery_extension +ADD CONSTRAINT + ry_extension_endpoint_id_fkey +FOREIGN KEY + (endpoint_id) +REFERENCES + ps_endpoints(sid) +ON DELETE CASCADE; + +-- migrate:down diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 11d0e48..b6dc4bc 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -165,14 +165,14 @@ paths: summary: Create a new endpoint. tags: - endpoints - /endpoints/{id}: + /endpoints/{sid}: delete: parameters: - - description: ID of the endpoint to be deleted + - description: Sid of the endpoint to be deleted in: path - name: id + name: sid required: true - type: string + type: integer responses: "204": description: No Content @@ -183,7 +183,6 @@ paths: summary: Delete an endpoint and its associated resources. tags: - endpoints - /endpoints/{sid}: get: parameters: - description: Requested endpoint's sid diff --git a/internal/handler/endpoint.go b/internal/handler/endpoint.go index 9cf0384..3e286f6 100644 --- a/internal/handler/endpoint.go +++ b/internal/handler/endpoint.go @@ -63,7 +63,7 @@ func (e *Endpoint) Router() chi.Router { r.Post("/", e.create) r.Get("/", e.list) r.Get("/{sid}", e.get) - r.Delete("/{id}", e.delete) + r.Delete("/{sid}", e.delete) return r } @@ -348,15 +348,16 @@ func (e *Endpoint) create(w http.ResponseWriter, r *http.Request) { } // @Summary Delete an endpoint and its associated resources. -// @Param id path string true "ID of the endpoint to be deleted" +// @Param sid path int true "Sid of the endpoint to be deleted" // @Success 204 // @Failure 400 // @Failure 500 // @Tags endpoints -// @Router /endpoints/{id} [delete] +// @Router /endpoints/{sid} [delete] func (e *Endpoint) delete(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - if id == "" { + urlSid := chi.URLParam(r, "sid") + sid, err := strconv.Atoi(urlSid) + if err != nil || sid <= 0 { w.WriteHeader(http.StatusBadRequest) return } @@ -370,7 +371,7 @@ func (e *Endpoint) delete(w http.ResponseWriter, r *http.Request) { queries := sqlc.New(tx) - err = queries.DeleteEndpoint(r.Context(), id) + id, err := queries.DeleteEndpoint(r.Context(), int32(sid)) if err != nil { w.WriteHeader(http.StatusInternalServerError) return diff --git a/internal/sqlc/queries.sql.go b/internal/sqlc/queries.sql.go index 95da9b7..d5ccc69 100644 --- a/internal/sqlc/queries.sql.go +++ b/internal/sqlc/queries.sql.go @@ -40,13 +40,15 @@ func (q *Queries) DeleteAuth(ctx context.Context, id string) error { return err } -const deleteEndpoint = `-- name: DeleteEndpoint :exec -DELETE FROM ps_endpoints WHERE id = $1 +const deleteEndpoint = `-- name: DeleteEndpoint :one +DELETE FROM ps_endpoints WHERE sid = $1 RETURNING id ` -func (q *Queries) DeleteEndpoint(ctx context.Context, id string) error { - _, err := q.db.Exec(ctx, deleteEndpoint, id) - return err +func (q *Queries) DeleteEndpoint(ctx context.Context, sid int32) (string, error) { + row := q.db.QueryRow(ctx, deleteEndpoint, sid) + var id string + err := row.Scan(&id) + return id, err } const getEndpointByExtension = `-- name: GetEndpointByExtension :one diff --git a/queries.sql b/queries.sql index 44c9c68..6f48293 100644 --- a/queries.sql +++ b/queries.sql @@ -17,8 +17,8 @@ VALUES ($1, $2, $1, $1, $3, 'all', $4, $5) RETURNING sid; --- name: DeleteEndpoint :exec -DELETE FROM ps_endpoints WHERE id = $1; +-- name: DeleteEndpoint :one +DELETE FROM ps_endpoints WHERE sid = $1 RETURNING id; -- name: DeleteAOR :exec DELETE FROM ps_aors WHERE id = $1; From 9a2a0d5527490668816d33b7ec74629cac51f3cd Mon Sep 17 00:00:00 2001 From: CrazyBolillo Date: Mon, 19 Aug 2024 17:23:06 -0600 Subject: [PATCH 5/6] ci: update golangci-lint --- .github/workflows/qa.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/qa.yaml b/.github/workflows/qa.yaml index 629739c..6544651 100644 --- a/.github/workflows/qa.yaml +++ b/.github/workflows/qa.yaml @@ -12,7 +12,7 @@ jobs: - uses: actions/setup-go@v5 with: go-version: '1.22' - - uses: golangci/golangci-lint-action@v5 + - uses: golangci/golangci-lint-action@v6 with: version: latest tests: From 2cf6f93129deb365cf3207a58000ec6e9c2f51d9 Mon Sep 17 00:00:00 2001 From: CrazyBolillo Date: Thu, 22 Aug 2024 17:04:50 -0600 Subject: [PATCH 6/6] feat(endpoint): support updates Endpoints may now be updated (patched). It is important to note that because Go's JSON marshalling can't detect between omitted fields and fields set to null, they are treated as the same (ommited). Once created only text fields may be set to NULL by updating the endpoint with an empty string. Realm was also removed from the API since there is no clear benefit at the moment. All use cases so far use the default value for it. --- docs/swagger.yaml | 42 +++++++- internal/handler/endpoint.go | 201 ++++++++++++++++++++++++++++++++++- internal/sqlc/queries.sql.go | 88 +++++++++++++++ queries.sql | 36 +++++++ 4 files changed, 360 insertions(+), 7 deletions(-) diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b6dc4bc..7df916f 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -33,8 +33,6 @@ definitions: type: integer password: type: string - realm: - type: string transport: type: string type: object @@ -83,6 +81,25 @@ definitions: total: type: integer type: object + handler.updateEndpointRequest: + properties: + codecs: + items: + type: string + type: array + context: + type: string + displayName: + type: string + extension: + type: string + maxContacts: + type: integer + password: + type: string + transport: + type: string + type: object host: localhost:8080 info: contact: {} @@ -204,4 +221,25 @@ paths: summary: Get information from a specific endpoint. tags: - endpoints + patch: + parameters: + - description: Sid of the endpoint to be updated + in: path + name: sid + required: true + type: integer + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.updateEndpointRequest' + "400": + description: Bad Request + "404": + description: Not Found + "500": + description: Internal Server Error + summary: Update the specified endpoint. Omitted or null fields will remain unchanged. + tags: + - endpoints swagger: "2.0" diff --git a/internal/handler/endpoint.go b/internal/handler/endpoint.go index 3e286f6..44b7089 100644 --- a/internal/handler/endpoint.go +++ b/internal/handler/endpoint.go @@ -17,6 +17,8 @@ import ( "strings" ) +const defaultRealm = "asterisk" + type Endpoint struct { *pgx.Conn } @@ -24,7 +26,6 @@ type Endpoint struct { type createEndpointRequest struct { ID string `json:"id"` Password string `json:"password"` - Realm string `json:"realm,omitempty"` Transport string `json:"transport,omitempty"` Context string `json:"context"` Codecs []string `json:"codecs"` @@ -58,12 +59,23 @@ type getEndpointResponse struct { Extension string `json:"extension"` } +type updateEndpointRequest struct { + Password *string `json:"password,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + Transport *string `json:"transport,omitempty"` + Context *string `json:"context,omitempty"` + Codecs []string `json:"codecs,omitempty"` + MaxContacts *int32 `json:"maxContacts,omitempty"` + Extension *string `json:"extension,omitempty"` +} + func (e *Endpoint) Router() chi.Router { r := chi.NewRouter() r.Post("/", e.create) r.Get("/", e.list) r.Get("/{sid}", e.get) r.Delete("/{sid}", e.delete) + r.Patch("/{sid}", e.update) return r } @@ -89,6 +101,11 @@ func displayNameFromClid(callerID string) string { return callerID[1:end] } +func hashPassword(user, password, realm string) string { + hash := md5.Sum([]byte(user + ":" + realm + ":" + password)) + return hex.EncodeToString(hash[:]) +} + // @Summary Get information from a specific endpoint. // @Param sid path int true "Requested endpoint's sid" // @Produce json @@ -235,7 +252,6 @@ func (e *Endpoint) list(w http.ResponseWriter, r *http.Request) { func (e *Endpoint) create(w http.ResponseWriter, r *http.Request) { decoder := json.NewDecoder(r.Body) payload := createEndpointRequest{ - Realm: "asterisk", MaxContacts: 1, } @@ -254,12 +270,11 @@ func (e *Endpoint) create(w http.ResponseWriter, r *http.Request) { queries := sqlc.New(tx) - hash := md5.Sum([]byte(payload.ID + ":" + payload.Realm + ":" + payload.Password)) err = queries.NewMD5Auth(r.Context(), sqlc.NewMD5AuthParams{ ID: payload.ID, Username: db.Text(payload.ID), - Realm: db.Text(payload.Realm), - Md5Cred: db.Text(hex.EncodeToString(hash[:])), + Realm: db.Text(defaultRealm), + Md5Cred: db.Text(hashPassword(payload.ID, payload.Password, defaultRealm)), }) if err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -397,3 +412,179 @@ func (e *Endpoint) delete(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } + +// @Summary Update the specified endpoint. Omitted or null fields will remain unchanged. +// @Param sid path int true "Sid of the endpoint to be updated" +// @Success 200 {object} updateEndpointRequest +// @Failure 400 +// @Failure 404 +// @Failure 500 +// @Tags endpoints +// @Router /endpoints/{sid} [patch] +func (e *Endpoint) update(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + var payload updateEndpointRequest + + err := decoder.Decode(&payload) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + urlSid := chi.URLParam(r, "sid") + sid, err := strconv.Atoi(urlSid) + if err != nil || sid <= 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + + tx, err := e.Begin(r.Context()) + if err != nil { + slog.Error("Failed to start transaction", slog.String("reason", err.Error()), slog.String("path", r.URL.Path)) + w.WriteHeader(http.StatusInternalServerError) + return + } + + queries := sqlc.New(tx) + endpoint, err := queries.GetEndpointByID(r.Context(), int32(sid)) + if errors.Is(err, pgx.ErrNoRows) { + w.WriteHeader(http.StatusNotFound) + return + } else if err != nil { + w.WriteHeader(http.StatusInternalServerError) + slog.Error("Failed to retrieve endpoint", slog.String("path", r.URL.Path), slog.String("reason", err.Error())) + return + } + + // Sorry for the incoming boilerplate but no dynamic SQL yet + var patchedEndpoint = sqlc.UpdateEndpointBySidParams{Sid: int32(sid)} + if payload.DisplayName != nil { + if *payload.DisplayName == "" { + patchedEndpoint.Callerid = db.Text("") + } else { + patchedEndpoint.Callerid = db.Text(fmt.Sprintf(`"%s" <%s>`, *payload.DisplayName, endpoint.ID)) + } + } else { + patchedEndpoint.Callerid = endpoint.Callerid + } + if payload.Context != nil { + patchedEndpoint.Context = db.Text(*payload.Context) + } else { + patchedEndpoint.Context = endpoint.Context + } + if payload.Transport != nil { + patchedEndpoint.Transport = db.Text(*payload.Transport) + } else { + patchedEndpoint.Transport = endpoint.Transport + } + if payload.Codecs != nil { + patchedEndpoint.Allow = db.Text(strings.Join(payload.Codecs, ",")) + } else { + patchedEndpoint.Allow = endpoint.Allow + } + err = queries.UpdateEndpointBySid(r.Context(), patchedEndpoint) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + slog.Error("Failed to update endpoint", slog.String("path", r.URL.Path), slog.String("reason", err.Error())) + return + } + + if payload.MaxContacts != nil { + err = queries.UpdateAORById( + r.Context(), + sqlc.UpdateAORByIdParams{ + ID: endpoint.ID, + MaxContacts: db.Int4(*payload.MaxContacts), + }, + ) + } + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + slog.Error("Failed to update AOR", slog.String("path", r.URL.Path), slog.String("reason", err.Error())) + return + } + + if payload.Extension != nil { + err = queries.UpdateExtensionByEndpointId( + r.Context(), + sqlc.UpdateExtensionByEndpointIdParams{ + EndpointID: int32(sid), + Extension: db.Text(*payload.Extension), + }, + ) + } + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + slog.Error("Failed to update extension", slog.String("path", r.URL.Path), slog.String("reason", err.Error())) + return + } + + if payload.Password != nil { + if len(*payload.Password) < 12 { + w.WriteHeader(http.StatusBadRequest) + slog.Info("Invalid password provided", slog.String("path", r.URL.Path)) + return + } + err = queries.UpdateMD5AuthById( + r.Context(), + sqlc.UpdateMD5AuthByIdParams{ + ID: endpoint.ID, + Md5Cred: db.Text(hashPassword(endpoint.ID, *payload.Password, defaultRealm)), + }, + ) + } + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + slog.Error("Failed to update password", slog.String("path", r.URL.Path), slog.String("reason", err.Error())) + return + } + + err = tx.Commit(r.Context()) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + slog.Error("Failed to commit update", slog.String("path", r.URL.Path), slog.String("reason", err.Error())) + return + } + + // TODO: Duplicate code, same as when fetching endpoint. Probably should put this into a service layer. + tx, err = e.Begin(r.Context()) + queries = sqlc.New(tx) + if err != nil { + slog.Error("Failed to create new transaction", slog.String("path", r.URL.Path), slog.String("reason", err.Error())) + w.WriteHeader(http.StatusInternalServerError) + return + } + res, err := queries.GetEndpointByID(r.Context(), int32(sid)) + if err != nil { + slog.Error( + "Failed to retrieve created endpoint", + slog.String("path", r.URL.Path), slog.String("reason", err.Error()), slog.Int("sid", int(sid)), + ) + w.WriteHeader(http.StatusInternalServerError) + return + } + + result := getEndpointResponse{ + Sid: int32(sid), + ID: res.ID, + Transport: res.Transport.String, + Context: res.Context.String, + Codecs: strings.Split(res.Allow.String, ","), + MaxContacts: res.MaxContacts.Int32, + Extension: res.Extension.String, + DisplayName: displayNameFromClid(res.Callerid.String), + } + content, err := json.Marshal(result) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + slog.Error("Failed to marshall response", slog.String("path", r.URL.Path)) + return + } + + w.Header().Set("Content-Type", "application/json") + _, err = w.Write(content) + if err != nil { + slog.Error("Failed to write response", slog.String("path", r.URL.Path), slog.String("reason", err.Error())) + } + w.WriteHeader(http.StatusOK) +} diff --git a/internal/sqlc/queries.sql.go b/internal/sqlc/queries.sql.go index d5ccc69..b0750f5 100644 --- a/internal/sqlc/queries.sql.go +++ b/internal/sqlc/queries.sql.go @@ -255,3 +255,91 @@ func (q *Queries) NewMD5Auth(ctx context.Context, arg NewMD5AuthParams) error { ) return err } + +const updateAORById = `-- name: UpdateAORById :exec +UPDATE + ps_aors +SET + max_contacts = $1 +WHERE + id = $2 +` + +type UpdateAORByIdParams struct { + MaxContacts pgtype.Int4 `json:"max_contacts"` + ID string `json:"id"` +} + +func (q *Queries) UpdateAORById(ctx context.Context, arg UpdateAORByIdParams) error { + _, err := q.db.Exec(ctx, updateAORById, arg.MaxContacts, arg.ID) + return err +} + +const updateEndpointBySid = `-- name: UpdateEndpointBySid :exec +UPDATE + ps_endpoints +SET + callerid = $1, + context = $2, + transport = $3, + allow = $4 +WHERE + sid = $5 +` + +type UpdateEndpointBySidParams struct { + Callerid pgtype.Text `json:"callerid"` + Context pgtype.Text `json:"context"` + Transport pgtype.Text `json:"transport"` + Allow pgtype.Text `json:"allow"` + Sid int32 `json:"sid"` +} + +func (q *Queries) UpdateEndpointBySid(ctx context.Context, arg UpdateEndpointBySidParams) error { + _, err := q.db.Exec(ctx, updateEndpointBySid, + arg.Callerid, + arg.Context, + arg.Transport, + arg.Allow, + arg.Sid, + ) + return err +} + +const updateExtensionByEndpointId = `-- name: UpdateExtensionByEndpointId :exec +UPDATE + ery_extension +SET + extension = $1 +WHERE + endpoint_id = $2 +` + +type UpdateExtensionByEndpointIdParams struct { + Extension pgtype.Text `json:"extension"` + EndpointID int32 `json:"endpoint_id"` +} + +func (q *Queries) UpdateExtensionByEndpointId(ctx context.Context, arg UpdateExtensionByEndpointIdParams) error { + _, err := q.db.Exec(ctx, updateExtensionByEndpointId, arg.Extension, arg.EndpointID) + return err +} + +const updateMD5AuthById = `-- name: UpdateMD5AuthById :exec +UPDATE + ps_auths +SET + md5_cred = $1 +WHERE + id = $2 +` + +type UpdateMD5AuthByIdParams struct { + Md5Cred pgtype.Text `json:"md5_cred"` + ID string `json:"id"` +} + +func (q *Queries) UpdateMD5AuthById(ctx context.Context, arg UpdateMD5AuthByIdParams) error { + _, err := q.db.Exec(ctx, updateMD5AuthById, arg.Md5Cred, arg.ID) + return err +} diff --git a/queries.sql b/queries.sql index 6f48293..b76dbc6 100644 --- a/queries.sql +++ b/queries.sql @@ -68,3 +68,39 @@ WHERE -- name: CountEndpoints :one SELECT COUNT(*) FROM ps_endpoints; + +-- name: UpdateEndpointBySid :exec +UPDATE + ps_endpoints +SET + callerid = $1, + context = $2, + transport = $3, + allow = $4 +WHERE + sid = $5; + +-- name: UpdateExtensionByEndpointId :exec +UPDATE + ery_extension +SET + extension = $1 +WHERE + endpoint_id = $2; + +-- name: UpdateAORById :exec +UPDATE + ps_aors +SET + max_contacts = $1 +WHERE + id = $2; + +-- name: UpdateMD5AuthById :exec +UPDATE + ps_auths +SET + md5_cred = $1 +WHERE + id = $2; +