diff --git a/routes/api/heartbeat.go b/routes/api/heartbeat.go index a639422..1362567 100644 --- a/routes/api/heartbeat.go +++ b/routes/api/heartbeat.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "encoding/json" "github.com/gorilla/mux" conf "github.com/muety/wakapi/config" @@ -9,6 +10,7 @@ import ( routeutils "github.com/muety/wakapi/routes/utils" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" + "io/ioutil" "net/http" "github.com/muety/wakapi/models" @@ -40,16 +42,20 @@ func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) { middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler, customMiddleware.NewWakatimeRelayMiddleware().Handler, ) - r.PathPrefix("/heartbeat").Methods(http.MethodPost).HandlerFunc(h.Post) - r.PathPrefix("/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post) - r.PathPrefix("/compat/wakatime/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post) + // see https://github.com/muety/wakapi/issues/203 + r.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(h.Post) + r.Path("/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post) + r.Path("/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post) + r.Path("/v1/users/{user}/heartbeats.bulk").Methods(http.MethodPost).HandlerFunc(h.Post) + r.Path("/compat/wakatime/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post) + r.Path("/compat/wakatime/v1/users/{user}/heartbeats.bulk").Methods(http.MethodPost).HandlerFunc(h.Post) } // @Summary Push a new heartbeat // @ID post-heartbeat // @Tags heartbeat // @Accept json -// @Param heartbeat body models.Heartbeat true "A heartbeat" +// @Param heartbeat body models.Heartbeat true "A single heartbeat" // @Security ApiKeyAuth // @Success 201 // @Router /heartbeat [post] @@ -60,16 +66,19 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) { } var heartbeats []*models.Heartbeat + heartbeats, err = h.tryParseBulk(r) + if err != nil { + heartbeats, err = h.tryParseSingle(r) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(err.Error())) + return + } + } + opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent")) machineName := r.Header.Get("X-Machine-Name") - dec := json.NewDecoder(r.Body) - if err := dec.Decode(&heartbeats); err != nil { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte(err.Error())) - return - } - for _, hb := range heartbeats { hb.OperatingSystem = opSys hb.Editor = editor @@ -103,12 +112,46 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) { } } + defer func() {}() + utils.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats))) } +func (h *HeartbeatApiHandler) tryParseBulk(r *http.Request) ([]*models.Heartbeat, error) { + var heartbeats []*models.Heartbeat + + body, _ := ioutil.ReadAll(r.Body) + r.Body.Close() + r.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + + dec := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(body))) + if err := dec.Decode(&heartbeats); err != nil { + return nil, err + } + + return heartbeats, nil +} + +func (h *HeartbeatApiHandler) tryParseSingle(r *http.Request) ([]*models.Heartbeat, error) { + var heartbeat models.Heartbeat + + body, _ := ioutil.ReadAll(r.Body) + r.Body.Close() + r.Body = ioutil.NopCloser(bytes.NewBuffer(body)) + + dec := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(body))) + if err := dec.Decode(&heartbeat); err != nil { + return nil, err + } + + return []*models.Heartbeat{&heartbeat}, nil +} + // construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288) // to make the cli consider all heartbeats to having been successfully saved // response looks like: { "responses": [ [ { "data": {...} }, 201 ], ... ] } +// update: this was probably a temporary bug at wakatime, responses actually looks like so: https://pastr.de/p/nyf6kj2e6843fbw4xkj4h4pj +// TODO: adapt response format some time func constructSuccessResponse(n int) *heartbeatResponseVm { responses := make([][]interface{}, n) @@ -123,3 +166,55 @@ func constructSuccessResponse(n int) *heartbeatResponseVm { Responses: responses, } } + +// Only for Swagger + +// @Summary Push a new heartbeat +// @ID post-heartbeat-2 +// @Tags heartbeat +// @Accept json +// @Param heartbeat body models.Heartbeat true "A single heartbeat" +// @Security ApiKeyAuth +// @Success 201 +// @Router /v1/users/{user}/heartbeats [post] +func (h *HeartbeatApiHandler) postAlias1() {} + +// @Summary Push a new heartbeat +// @ID post-heartbeat-3 +// @Tags heartbeat +// @Accept json +// @Param heartbeat body models.Heartbeat true "A single heartbeat" +// @Security ApiKeyAuth +// @Success 201 +// @Router /compat/wakatime/v1/users/{user}/heartbeats [post] +func (h *HeartbeatApiHandler) postAlias2() {} + +// @Summary Push new heartbeats +// @ID post-heartbeat-4 +// @Tags heartbeat +// @Accept json +// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats" +// @Security ApiKeyAuth +// @Success 201 +// @Router /heartbeats [post] +func (h *HeartbeatApiHandler) postAlias3() {} + +// @Summary Push new heartbeats +// @ID post-heartbeat-5 +// @Tags heartbeat +// @Accept json +// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats" +// @Security ApiKeyAuth +// @Success 201 +// @Router /v1/users/{user}/heartbeats.bulk [post] +func (h *HeartbeatApiHandler) postAlias4() {} + +// @Summary Push new heartbeats +// @ID post-heartbeat-6 +// @Tags heartbeat +// @Accept json +// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats" +// @Security ApiKeyAuth +// @Success 201 +// @Router /compat/wakatime/v1/users/{user}/heartbeats.bulk [post] +func (h *HeartbeatApiHandler) postAlias5() {} diff --git a/static/docs/docs.go b/static/docs/docs.go index e7e098d..3cc4cb6 100644 --- a/static/docs/docs.go +++ b/static/docs/docs.go @@ -160,6 +160,75 @@ var doc = `{ } } }, + "/compat/wakatime/v1/users/{user}/heartbeats": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "heartbeat" + ], + "summary": "Push a new heartbeat", + "operationId": "post-heartbeat-3", + "parameters": [ + { + "description": "A single heartbeat", + "name": "heartbeat", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Heartbeat" + } + } + ], + "responses": { + "201": { + "description": "" + } + } + } + }, + "/compat/wakatime/v1/users/{user}/heartbeats.bulk": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "heartbeat" + ], + "summary": "Push new heartbeats", + "operationId": "post-heartbeat-6", + "parameters": [ + { + "description": "Multiple heartbeats", + "name": "heartbeat", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Heartbeat" + } + } + } + ], + "responses": { + "201": { + "description": "" + } + } + } + }, "/compat/wakatime/v1/users/{user}/projects": { "get": { "security": [ @@ -361,7 +430,7 @@ var doc = `{ "operationId": "post-heartbeat", "parameters": [ { - "description": "A heartbeat", + "description": "A single heartbeat", "name": "heartbeat", "in": "body", "required": true, @@ -377,6 +446,42 @@ var doc = `{ } } }, + "/heartbeats": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "heartbeat" + ], + "summary": "Push new heartbeats", + "operationId": "post-heartbeat-4", + "parameters": [ + { + "description": "Multiple heartbeats", + "name": "heartbeat", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Heartbeat" + } + } + } + ], + "responses": { + "201": { + "description": "" + } + } + } + }, "/summary": { "get": { "security": [ @@ -441,6 +546,75 @@ var doc = `{ } } } + }, + "/v1/users/{user}/heartbeats": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "heartbeat" + ], + "summary": "Push a new heartbeat", + "operationId": "post-heartbeat-2", + "parameters": [ + { + "description": "A single heartbeat", + "name": "heartbeat", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Heartbeat" + } + } + ], + "responses": { + "201": { + "description": "" + } + } + } + }, + "/v1/users/{user}/heartbeats.bulk": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "heartbeat" + ], + "summary": "Push new heartbeats", + "operationId": "post-heartbeat-5", + "parameters": [ + { + "description": "Multiple heartbeats", + "name": "heartbeat", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Heartbeat" + } + } + } + ], + "responses": { + "201": { + "description": "" + } + } + } } }, "definitions": { @@ -506,6 +680,12 @@ var doc = `{ "format": "date", "example": "2006-01-02 15:04:05.000" }, + "labels": { + "type": "array", + "items": { + "$ref": "#/definitions/models.SummaryItem" + } + }, "languages": { "type": "array", "items": { diff --git a/static/docs/swagger.json b/static/docs/swagger.json index 1fcf65a..6764e80 100644 --- a/static/docs/swagger.json +++ b/static/docs/swagger.json @@ -144,6 +144,75 @@ } } }, + "/compat/wakatime/v1/users/{user}/heartbeats": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "heartbeat" + ], + "summary": "Push a new heartbeat", + "operationId": "post-heartbeat-3", + "parameters": [ + { + "description": "A single heartbeat", + "name": "heartbeat", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Heartbeat" + } + } + ], + "responses": { + "201": { + "description": "" + } + } + } + }, + "/compat/wakatime/v1/users/{user}/heartbeats.bulk": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "heartbeat" + ], + "summary": "Push new heartbeats", + "operationId": "post-heartbeat-6", + "parameters": [ + { + "description": "Multiple heartbeats", + "name": "heartbeat", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Heartbeat" + } + } + } + ], + "responses": { + "201": { + "description": "" + } + } + } + }, "/compat/wakatime/v1/users/{user}/projects": { "get": { "security": [ @@ -345,7 +414,7 @@ "operationId": "post-heartbeat", "parameters": [ { - "description": "A heartbeat", + "description": "A single heartbeat", "name": "heartbeat", "in": "body", "required": true, @@ -361,6 +430,42 @@ } } }, + "/heartbeats": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "heartbeat" + ], + "summary": "Push new heartbeats", + "operationId": "post-heartbeat-4", + "parameters": [ + { + "description": "Multiple heartbeats", + "name": "heartbeat", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Heartbeat" + } + } + } + ], + "responses": { + "201": { + "description": "" + } + } + } + }, "/summary": { "get": { "security": [ @@ -425,6 +530,75 @@ } } } + }, + "/v1/users/{user}/heartbeats": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "heartbeat" + ], + "summary": "Push a new heartbeat", + "operationId": "post-heartbeat-2", + "parameters": [ + { + "description": "A single heartbeat", + "name": "heartbeat", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Heartbeat" + } + } + ], + "responses": { + "201": { + "description": "" + } + } + } + }, + "/v1/users/{user}/heartbeats.bulk": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "tags": [ + "heartbeat" + ], + "summary": "Push new heartbeats", + "operationId": "post-heartbeat-5", + "parameters": [ + { + "description": "Multiple heartbeats", + "name": "heartbeat", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Heartbeat" + } + } + } + ], + "responses": { + "201": { + "description": "" + } + } + } } }, "definitions": { @@ -490,6 +664,12 @@ "format": "date", "example": "2006-01-02 15:04:05.000" }, + "labels": { + "type": "array", + "items": { + "$ref": "#/definitions/models.SummaryItem" + } + }, "languages": { "type": "array", "items": { diff --git a/static/docs/swagger.yaml b/static/docs/swagger.yaml index e1542cc..d39a35b 100644 --- a/static/docs/swagger.yaml +++ b/static/docs/swagger.yaml @@ -43,6 +43,10 @@ definitions: example: "2006-01-02 15:04:05.000" format: date type: string + labels: + items: + $ref: '#/definitions/models.SummaryItem' + type: array languages: items: $ref: '#/definitions/models.SummaryItem' @@ -408,6 +412,48 @@ paths: summary: Retrieve summary for all time tags: - wakatime + /compat/wakatime/v1/users/{user}/heartbeats: + post: + consumes: + - application/json + operationId: post-heartbeat-3 + parameters: + - description: A single heartbeat + in: body + name: heartbeat + required: true + schema: + $ref: '#/definitions/models.Heartbeat' + responses: + "201": + description: "" + security: + - ApiKeyAuth: [] + summary: Push a new heartbeat + tags: + - heartbeat + /compat/wakatime/v1/users/{user}/heartbeats.bulk: + post: + consumes: + - application/json + operationId: post-heartbeat-6 + parameters: + - description: Multiple heartbeats + in: body + name: heartbeat + required: true + schema: + items: + $ref: '#/definitions/models.Heartbeat' + type: array + responses: + "201": + description: "" + security: + - ApiKeyAuth: [] + summary: Push new heartbeats + tags: + - heartbeat /compat/wakatime/v1/users/{user}/projects: get: description: Mimics https://wakatime.com/developers#projects @@ -540,7 +586,7 @@ paths: - application/json operationId: post-heartbeat parameters: - - description: A heartbeat + - description: A single heartbeat in: body name: heartbeat required: true @@ -554,6 +600,28 @@ paths: summary: Push a new heartbeat tags: - heartbeat + /heartbeats: + post: + consumes: + - application/json + operationId: post-heartbeat-4 + parameters: + - description: Multiple heartbeats + in: body + name: heartbeat + required: true + schema: + items: + $ref: '#/definitions/models.Heartbeat' + type: array + responses: + "201": + description: "" + security: + - ApiKeyAuth: [] + summary: Push new heartbeats + tags: + - heartbeat /summary: get: operationId: get-summary @@ -599,6 +667,48 @@ paths: summary: Retrieve a summary tags: - summary + /v1/users/{user}/heartbeats: + post: + consumes: + - application/json + operationId: post-heartbeat-2 + parameters: + - description: A single heartbeat + in: body + name: heartbeat + required: true + schema: + $ref: '#/definitions/models.Heartbeat' + responses: + "201": + description: "" + security: + - ApiKeyAuth: [] + summary: Push a new heartbeat + tags: + - heartbeat + /v1/users/{user}/heartbeats.bulk: + post: + consumes: + - application/json + operationId: post-heartbeat-5 + parameters: + - description: Multiple heartbeats + in: body + name: heartbeat + required: true + schema: + items: + $ref: '#/definitions/models.Heartbeat' + type: array + responses: + "201": + description: "" + security: + - ApiKeyAuth: [] + summary: Push new heartbeats + tags: + - heartbeat securityDefinitions: ApiKeyAuth: in: header diff --git a/version.txt b/version.txt index bf4df28..ac786b6 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.28.2 +1.28.3