feat: GET /heartbeat endpoint (resolves #241)

This commit is contained in:
Steven Tang 2022-01-28 22:28:47 +11:00 committed by Ferdinand Mütsch
parent 7159df30c2
commit e7f3432113
9 changed files with 638 additions and 9 deletions

View File

@ -2,9 +2,6 @@ package main
import (
"embed"
"github.com/lpar/gzipped/v2"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes/relay"
"io/fs"
"log"
"net"
@ -13,6 +10,10 @@ import (
"strconv"
"time"
"github.com/lpar/gzipped/v2"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes/relay"
"github.com/emvi/logbuch"
"github.com/gorilla/handlers"
conf "github.com/muety/wakapi/config"
@ -187,6 +188,7 @@ func main() {
wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService)
wakatimeV1UsersHandler := wtV1Routes.NewUsersHandler(userService, heartbeatService)
wakatimeV1ProjectsHandler := wtV1Routes.NewProjectsHandler(userService, heartbeatService)
wakatimeV1HeartbeatsHandler := wtV1Routes.NewHeartbeatHandler(userService, heartbeatService)
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
// MVC Handlers
@ -241,6 +243,7 @@ func main() {
wakatimeV1StatsHandler.RegisterRoutes(apiRouter)
wakatimeV1UsersHandler.RegisterRoutes(apiRouter)
wakatimeV1ProjectsHandler.RegisterRoutes(apiRouter)
wakatimeV1HeartbeatsHandler.RegisterRoutes(apiRouter)
shieldV1BadgeHandler.RegisterRoutes(apiRouter)
// Static Routes

View File

@ -1,6 +1,8 @@
package v1
import (
"strconv"
"github.com/muety/wakapi/models"
)
@ -19,11 +21,34 @@ type HeartbeatEntry struct {
IsWrite bool `json:"is_write"`
Language string `json:"language"`
Project string `json:"project"`
Time models.CustomTime `json:"time"`
Time float64 `json:"time"`
Type string `json:"type"`
UserId string `json:"user_id"`
MachineNameId string `json:"machine_name_id"`
UserAgentId string `json:"user_agent_id"`
CreatedAt models.CustomTime `json:"created_at"`
ModifiedAt models.CustomTime `json:"created_at"`
ModifiedAt models.CustomTime `json:"created_at",omitempty`
}
func ToHeartbeatEntry(entries []*models.Heartbeat) []HeartbeatEntry {
out := make([]HeartbeatEntry, len(entries))
for i := 0; i < len(entries); i++ {
entry := entries[i]
out[i] = HeartbeatEntry{
Id: strconv.FormatUint(entry.ID, 10),
Branch: entry.Branch,
Category: entry.Category,
Entity: entry.Entity,
IsWrite: entry.IsWrite,
Language: entry.Language,
Project: entry.Project,
Time: float64(entry.Time.T().Unix()),
Type: entry.Type,
UserId: entry.UserID,
MachineNameId: entry.Machine,
UserAgentId: entry.UserAgent,
CreatedAt: entry.CreatedAt,
}
}
return out
}

View File

@ -145,6 +145,7 @@ func constructSuccessResponse(n int) *heartbeatResponseVm {
// @Tags heartbeat
// @Accept json
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Param user path string true "Username (or current)"
// @Security ApiKeyAuth
// @Success 201
// @Router /v1/users/{user}/heartbeats [post]
@ -155,6 +156,7 @@ func (h *HeartbeatApiHandler) postAlias1() {}
// @Tags heartbeat
// @Accept json
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Param user path string true "Username (or current)"
// @Security ApiKeyAuth
// @Success 201
// @Router /compat/wakatime/v1/users/{user}/heartbeats [post]
@ -165,6 +167,7 @@ func (h *HeartbeatApiHandler) postAlias2() {}
// @Tags heartbeat
// @Accept json
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Param user path string true "Username (or current)"
// @Security ApiKeyAuth
// @Success 201
// @Router /users/{user}/heartbeats [post]
@ -185,6 +188,7 @@ func (h *HeartbeatApiHandler) postAlias4() {}
// @Tags heartbeat
// @Accept json
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Param user path string true "Username (or current)"
// @Security ApiKeyAuth
// @Success 201
// @Router /v1/users/{user}/heartbeats.bulk [post]
@ -195,6 +199,7 @@ func (h *HeartbeatApiHandler) postAlias5() {}
// @Tags heartbeat
// @Accept json
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Param user path string true "Username (or current)"
// @Security ApiKeyAuth
// @Success 201
// @Router /compat/wakatime/v1/users/{user}/heartbeats.bulk [post]
@ -205,6 +210,7 @@ func (h *HeartbeatApiHandler) postAlias6() {}
// @Tags heartbeat
// @Accept json
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Param user path string true "Username (or current)"
// @Security ApiKeyAuth
// @Success 201
// @Router /users/{user}/heartbeats.bulk [post]

View File

@ -0,0 +1,85 @@
package v1
import (
"net/http"
"time"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
wakatime "github.com/muety/wakapi/models/compat/wakatime/v1"
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
)
type HeartbeatsResult struct {
Data []wakatime.HeartbeatEntry `json:"data"`
End string `json:"end"`
Start string `json:"start"`
Timezone string `json:"timezone"`
}
type HeartbeatHandler struct {
userSrvc services.IUserService
heartbeatSrvc services.IHeartbeatService
}
func NewHeartbeatHandler(userService services.IUserService, heartbeatService services.IHeartbeatService) *HeartbeatHandler {
return &HeartbeatHandler{
userSrvc: userService,
heartbeatSrvc: heartbeatService,
}
}
func (h *HeartbeatHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Path("/compat/wakatime/v1/users/{user}/heartbeats").Methods(http.MethodGet).HandlerFunc(h.Get)
}
// @Summary Get heartbeats of user for specified date
// @ID get-heartbeats
// @Tags heartbeat
// @Param date query string true "Date"
// @Param user path string true "Username (or current)"
// @Security ApiKeyAuth
// @Success 200 {object} v1.HeartbeatEntry
// @Failure 400 {string} string "bad date"
// @Router /compat/wakatime/v1/users/{user}/heartbeats [get]
func (h *HeartbeatHandler) Get(w http.ResponseWriter, r *http.Request) {
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
if err != nil {
return // response was already sent by util function
}
params := r.URL.Query()
dateParam := params.Get("date")
date, err := time.Parse(conf.SimpleDateFormat, dateParam)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("bad date"))
return
}
timezone := user.TZ()
rangeFrom, rangeTo := utils.StartOfDay(date.In(timezone)), utils.EndOfDay(date.In(timezone))
heartbeats, err := h.heartbeatSrvc.GetAllWithin(rangeFrom, rangeTo, user)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError))
conf.Log().Request(r).Error("failed to retrieve heartbeats - %v", err)
return
}
res := HeartbeatsResult{
Data: wakatime.ToHeartbeatEntry(heartbeats),
Start: rangeFrom.UTC().Format(time.RFC3339),
End: rangeTo.UTC().Format(time.RFC3339),
Timezone: timezone.String(),
}
utils.RespondJSON(w, r, http.StatusOK, res)
}

View File

@ -6,6 +6,9 @@ import (
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
@ -13,8 +16,6 @@ import (
"github.com/muety/wakapi/utils"
"go.uber.org/atomic"
"golang.org/x/sync/semaphore"
"net/http"
"time"
)
const OriginWakatime = "wakatime"
@ -284,7 +285,7 @@ func mapHeartbeat(
OperatingSystem: ua.Os,
Machine: ma.Value,
UserAgent: ua.Value,
Time: entry.Time,
Time: models.CustomTime(time.Unix(0, int64(entry.Time*1e9))),
Origin: OriginWakatime,
OriginId: entry.Id,
}).Hashed()

View File

@ -160,6 +160,48 @@ var doc = `{
}
},
"/compat/wakatime/v1/users/{user}/heartbeats": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"tags": [
"heartbeat"
],
"summary": "Get heartbeats of user for specified date",
"operationId": "get-heartbeats",
"parameters": [
{
"type": "string",
"description": "Date",
"name": "date",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Username (or current)",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.HeartbeatEntry"
}
},
"400": {
"description": "bad date",
"schema": {
"type": "string"
}
}
}
},
"post": {
"security": [
{
@ -183,6 +225,13 @@ var doc = `{
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
},
{
"type": "string",
"description": "Username (or current)",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
@ -219,6 +268,13 @@ var doc = `{
"$ref": "#/definitions/models.Heartbeat"
}
}
},
{
"type": "string",
"description": "Username (or current)",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
@ -863,6 +919,13 @@ var doc = `{
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
},
{
"type": "string",
"description": "Username (or current)",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
@ -899,6 +962,13 @@ var doc = `{
"$ref": "#/definitions/models.Heartbeat"
}
}
},
{
"type": "string",
"description": "Username (or current)",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
@ -967,6 +1037,13 @@ var doc = `{
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
},
{
"type": "string",
"description": "Username (or current)",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
@ -1003,6 +1080,13 @@ var doc = `{
"$ref": "#/definitions/models.Heartbeat"
}
}
},
{
"type": "string",
"description": "Username (or current)",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
@ -1094,6 +1178,13 @@ var doc = `{
"models.Summary": {
"type": "object",
"properties": {
"branches": {
"description": "branches are not persisted, but calculated at runtime in case a project filter is applied",
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"editors": {
"type": "array",
"items": {
@ -1222,6 +1313,50 @@ var doc = `{
}
}
},
"v1.HeartbeatEntry": {
"type": "object",
"properties": {
"branch": {
"type": "string"
},
"category": {
"type": "string"
},
"created_at": {
"type": "string"
},
"entity": {
"type": "string"
},
"id": {
"type": "string"
},
"is_write": {
"type": "boolean"
},
"language": {
"type": "string"
},
"machine_name_id": {
"type": "string"
},
"project": {
"type": "string"
},
"time": {
"type": "number"
},
"type": {
"type": "string"
},
"user_agent_id": {
"type": "string"
},
"user_id": {
"type": "string"
}
}
},
"v1.Project": {
"type": "object",
"properties": {
@ -1250,6 +1385,12 @@ var doc = `{
"v1.StatsData": {
"type": "object",
"properties": {
"branches": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"daily_average": {
"type": "number"
},
@ -1325,6 +1466,12 @@ var doc = `{
"v1.SummariesData": {
"type": "object",
"properties": {
"branches": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"categories": {
"type": "array",
"items": {

View File

@ -145,6 +145,48 @@
}
},
"/compat/wakatime/v1/users/{user}/heartbeats": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"tags": [
"heartbeat"
],
"summary": "Get heartbeats of user for specified date",
"operationId": "get-heartbeats",
"parameters": [
{
"type": "string",
"description": "Date",
"name": "date",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Username (or current)",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/v1.HeartbeatEntry"
}
},
"400": {
"description": "bad date",
"schema": {
"type": "string"
}
}
}
},
"post": {
"security": [
{
@ -168,6 +210,13 @@
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
},
{
"type": "string",
"description": "Username (or current)",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
@ -204,6 +253,13 @@
"$ref": "#/definitions/models.Heartbeat"
}
}
},
{
"type": "string",
"description": "Username (or current)",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
@ -848,6 +904,13 @@
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
},
{
"type": "string",
"description": "Username (or current)",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
@ -884,6 +947,13 @@
"$ref": "#/definitions/models.Heartbeat"
}
}
},
{
"type": "string",
"description": "Username (or current)",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
@ -952,6 +1022,13 @@
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
},
{
"type": "string",
"description": "Username (or current)",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
@ -988,6 +1065,13 @@
"$ref": "#/definitions/models.Heartbeat"
}
}
},
{
"type": "string",
"description": "Username (or current)",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
@ -1079,6 +1163,13 @@
"models.Summary": {
"type": "object",
"properties": {
"branches": {
"description": "branches are not persisted, but calculated at runtime in case a project filter is applied",
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"editors": {
"type": "array",
"items": {
@ -1207,6 +1298,50 @@
}
}
},
"v1.HeartbeatEntry": {
"type": "object",
"properties": {
"branch": {
"type": "string"
},
"category": {
"type": "string"
},
"created_at": {
"type": "string"
},
"entity": {
"type": "string"
},
"id": {
"type": "string"
},
"is_write": {
"type": "boolean"
},
"language": {
"type": "string"
},
"machine_name_id": {
"type": "string"
},
"project": {
"type": "string"
},
"time": {
"type": "number"
},
"type": {
"type": "string"
},
"user_agent_id": {
"type": "string"
},
"user_id": {
"type": "string"
}
}
},
"v1.Project": {
"type": "object",
"properties": {
@ -1235,6 +1370,12 @@
"v1.StatsData": {
"type": "object",
"properties": {
"branches": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"daily_average": {
"type": "number"
},
@ -1310,6 +1451,12 @@
"v1.SummariesData": {
"type": "object",
"properties": {
"branches": {
"type": "array",
"items": {
"$ref": "#/definitions/v1.SummariesEntry"
}
},
"categories": {
"type": "array",
"items": {

View File

@ -54,6 +54,12 @@ definitions:
type: object
models.Summary:
properties:
branches:
description: branches are not persisted, but calculated at runtime in case
a project filter is applied
items:
$ref: '#/definitions/models.SummaryItem'
type: array
editors:
items:
$ref: '#/definitions/models.SummaryItem'
@ -142,6 +148,35 @@ definitions:
schemaVersion:
type: integer
type: object
v1.HeartbeatEntry:
properties:
branch:
type: string
category:
type: string
created_at:
type: string
entity:
type: string
id:
type: string
is_write:
type: boolean
language:
type: string
machine_name_id:
type: string
project:
type: string
time:
type: number
type:
type: string
user_agent_id:
type: string
user_id:
type: string
type: object
v1.Project:
properties:
id:
@ -160,6 +195,10 @@ definitions:
type: object
v1.StatsData:
properties:
branches:
items:
$ref: '#/definitions/v1.SummariesEntry'
type: array
daily_average:
type: number
days_including_holidays:
@ -209,6 +248,10 @@ definitions:
type: object
v1.SummariesData:
properties:
branches:
items:
$ref: '#/definitions/v1.SummariesEntry'
type: array
categories:
items:
$ref: '#/definitions/v1.SummariesEntry'
@ -441,6 +484,33 @@ paths:
tags:
- wakatime
/compat/wakatime/v1/users/{user}/heartbeats:
get:
operationId: get-heartbeats
parameters:
- description: Date
in: query
name: date
required: true
type: string
- description: Username (or current)
in: path
name: user
required: true
type: string
responses:
"200":
description: OK
schema:
$ref: '#/definitions/v1.HeartbeatEntry'
"400":
description: bad date
schema:
type: string
security:
- ApiKeyAuth: []
summary: Get heartbeats of user for specified date
tags:
- heartbeat
post:
consumes:
- application/json
@ -452,6 +522,11 @@ paths:
required: true
schema:
$ref: '#/definitions/models.Heartbeat'
- description: Username (or current)
in: path
name: user
required: true
type: string
responses:
"201":
description: ""
@ -474,6 +549,11 @@ paths:
items:
$ref: '#/definitions/models.Heartbeat'
type: array
- description: Username (or current)
in: path
name: user
required: true
type: string
responses:
"201":
description: ""
@ -900,6 +980,11 @@ paths:
required: true
schema:
$ref: '#/definitions/models.Heartbeat'
- description: Username (or current)
in: path
name: user
required: true
type: string
responses:
"201":
description: ""
@ -922,6 +1007,11 @@ paths:
items:
$ref: '#/definitions/models.Heartbeat'
type: array
- description: Username (or current)
in: path
name: user
required: true
type: string
responses:
"201":
description: ""
@ -965,6 +1055,11 @@ paths:
required: true
schema:
$ref: '#/definitions/models.Heartbeat'
- description: Username (or current)
in: path
name: user
required: true
type: string
responses:
"201":
description: ""
@ -987,6 +1082,11 @@ paths:
items:
$ref: '#/definitions/models.Heartbeat'
type: array
- description: Username (or current)
in: path
name: user
required: true
type: string
responses:
"201":
description: ""

View File

@ -965,6 +965,121 @@
}
},
"response": []
},
{
"name": "Create heartbeats (get heartbeats test)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"// 1640995199 Friday, 31 December 2021 11:59:59 PM (Jan 1st in +1, +2)",
"// 1641074399 Saturday, 1 January 2022 9:59:59 PM (Jan 1st in +1, +2)",
"// 1641081599 Saturday, 1 January 2022 11:59:59 PM (Jan 2nd in +1, +2)",
""
],
"type": "text/javascript"
}
}
],
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{WRITEUSER_TOKEN}}",
"type": "string"
}
]
},
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "[{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1640995199\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641074399\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641081599\n}]",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE_URL}}/api/heartbeat",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"heartbeat"
]
}
},
"response": []
},
{
"name": "Get heartbeats",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 200\", function () {",
" pm.response.to.have.status(200);",
"});",
"",
"pm.test(\"Response body is correct\", function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.timezone).to.eql(pm.collectionVariables.get('TZ'));",
" var date = new Date(\"2022-01-01T00:00:00+0100\")",
" pm.expect(new Date(jsonData.start)).to.eql(date);",
" pm.expect(new Date(jsonData.end)).to.eql(new Date(date.getTime() + 3600 * 1000 * 24));",
" pm.expect(jsonData.data.length).to.eql(2);",
"});"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableCookies": true
},
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{WRITEUSER_TOKEN}}",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats?date=2022-01-01",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"compat",
"wakatime",
"v1",
"users",
"current",
"heartbeats"
],
"query": [
{
"key": "date",
"value": "2022-01-01"
}
]
}
},
"response": []
}
]
},
@ -1982,7 +2097,7 @@
"",
"pm.test(\"Correct content\", function () {",
" const jsonData = pm.response.json();",
" pm.expect(jsonData.data.text).to.eql('0 hrs 2 mins');",
" pm.expect(jsonData.data.text).to.eql('0 hrs 8 mins');",
"});"
],
"type": "text/javascript"