1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

Compare commits

...

7 Commits

11 changed files with 1889 additions and 683 deletions

View File

@ -151,6 +151,8 @@ You can specify configuration options either via a config file (default: `config
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on | | `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) | | `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
| `server.listen_ipv6` | `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) | | `server.listen_ipv6` | `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
| `server.listen_socket` | `WAKAPI_LISTEN_SOCKET` | - | UNIX socket to listen on (leave blank to disable UNIX socket) |
| `server.timeout_sec` | `WAKAPI_TIMEOUT_SEC` | `30` | Request timeout in seconds |
| `server.tls_cert_path` | `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) | | `server.tls_cert_path` | `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
| `server.tls_key_path` | `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) | | `server.tls_key_path` | `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) | | `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |

View File

@ -3,6 +3,8 @@ env: production
server: server:
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4 listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
listen_ipv6: ::1 # leave blank to disable ipv6 listen_ipv6: ::1 # leave blank to disable ipv6
listen_socket: # leave blank to disable unix sockets
timeout_sec: 30 # request timeout
tls_cert_path: # leave blank to not use https tls_cert_path: # leave blank to not use https
tls_key_path: # leave blank to not use https tls_key_path: # leave blank to not use https
port: 3000 port: 3000

View File

@ -98,6 +98,8 @@ type serverConfig struct {
Port int `default:"3000" env:"WAKAPI_PORT"` Port int `default:"3000" env:"WAKAPI_PORT"`
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"` ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"` ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
ListenSocket string `yaml:"listen_socket" default:"" env:"WAKAPI_LISTEN_SOCKET"`
TimeoutSec int `yaml:"timeout_sec" default:"30" env:"WAKAPI_TIMEOUT_SEC"`
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"` BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"WAKAPI_PUBLIC_URL"` PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"WAKAPI_PUBLIC_URL"`
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"` TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
@ -350,8 +352,8 @@ func Load(version string) *Config {
} }
// some validation checks // some validation checks
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" { if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" && config.Server.ListenSocket == "" {
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 must be set") logbuch.Fatal("either of listen_ipv4 or listen_ipv6 or listen_socket must be set")
} }
if config.Db.MaxConn <= 0 { if config.Db.MaxConn <= 0 {
logbuch.Fatal("you must allow at least one database connection") logbuch.Fatal("you must allow at least one database connection")

File diff suppressed because it is too large Load Diff

52
main.go
View File

@ -4,6 +4,7 @@ import (
"embed" "embed"
"io/fs" "io/fs"
"log" "log"
"net"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
@ -188,6 +189,14 @@ func main() {
rootRouter := router.PathPrefix("/").Subrouter() rootRouter := router.PathPrefix("/").Subrouter()
apiRouter := router.PathPrefix("/api").Subrouter().StrictSlash(true) apiRouter := router.PathPrefix("/api").Subrouter().StrictSlash(true)
// https://github.com/gorilla/mux/issues/416
router.NotFoundHandler = router.NewRoute().BuildOnly().HandlerFunc(http.NotFound).GetHandler()
router.NotFoundHandler = middlewares.NewLoggingMiddleware(logbuch.Info, []string{
"/assets",
"/favicon",
"/service-worker.js",
})(router.NotFoundHandler)
// Globally used middlewares // Globally used middlewares
router.Use(middlewares.NewPrincipalMiddleware()) router.Use(middlewares.NewPrincipalMiddleware())
router.Use(middlewares.NewLoggingMiddleware(logbuch.Info, []string{"/assets"})) router.Use(middlewares.NewLoggingMiddleware(logbuch.Info, []string{"/assets"}))
@ -233,7 +242,7 @@ func main() {
} }
func listen(handler http.Handler) { func listen(handler http.Handler) {
var s4, s6 *http.Server var s4, s6, sSocket *http.Server
// IPv4 // IPv4
if config.Server.ListenIpV4 != "" { if config.Server.ListenIpV4 != "" {
@ -241,8 +250,8 @@ func listen(handler http.Handler) {
s4 = &http.Server{ s4 = &http.Server{
Handler: handler, Handler: handler,
Addr: bindString4, Addr: bindString4,
ReadTimeout: 10 * time.Second, ReadTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
WriteTimeout: 10 * time.Second, WriteTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
} }
} }
@ -252,8 +261,17 @@ func listen(handler http.Handler) {
s6 = &http.Server{ s6 = &http.Server{
Handler: handler, Handler: handler,
Addr: bindString6, Addr: bindString6,
ReadTimeout: 10 * time.Second, ReadTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
WriteTimeout: 10 * time.Second, WriteTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
}
}
// UNIX domain socket
if config.Server.ListenSocket != "" {
sSocket = &http.Server{
Handler: handler,
ReadTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
WriteTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
} }
} }
@ -274,6 +292,18 @@ func listen(handler http.Handler) {
} }
}() }()
} }
if sSocket != nil {
logbuch.Info("--> Listening for HTTPS on %s... ✅", config.Server.ListenSocket)
go func() {
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
if err != nil {
logbuch.Fatal(err.Error())
}
if err := sSocket.ServeTLS(unixListener, config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
logbuch.Fatal(err.Error())
}
}()
}
} else { } else {
if s4 != nil { if s4 != nil {
logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr) logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr)
@ -291,6 +321,18 @@ func listen(handler http.Handler) {
} }
}() }()
} }
if sSocket != nil {
logbuch.Info("--> Listening for HTTP on %s... ✅", config.Server.ListenSocket)
go func() {
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
if err != nil {
logbuch.Fatal(err.Error())
}
if err := sSocket.Serve(unixListener); err != nil {
logbuch.Fatal(err.Error())
}
}()
}
} }
<-make(chan interface{}, 1) <-make(chan interface{}, 1)

View File

@ -1,6 +1,7 @@
package api package api
import ( import (
"bytes"
"encoding/json" "encoding/json"
"github.com/gorilla/mux" "github.com/gorilla/mux"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
@ -9,6 +10,7 @@ import (
routeutils "github.com/muety/wakapi/routes/utils" routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"io/ioutil"
"net/http" "net/http"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
@ -40,16 +42,22 @@ func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler, middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
customMiddleware.NewWakatimeRelayMiddleware().Handler, customMiddleware.NewWakatimeRelayMiddleware().Handler,
) )
r.PathPrefix("/heartbeat").Methods(http.MethodPost).HandlerFunc(h.Post) // see https://github.com/muety/wakapi/issues/203
r.PathPrefix("/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post) r.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(h.Post)
r.PathPrefix("/compat/wakatime/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post) r.Path("/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post)
r.Path("/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post)
r.Path("/users/{user}/heartbeats.bulk").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 // @Summary Push a new heartbeat
// @ID post-heartbeat // @ID post-heartbeat
// @Tags heartbeat // @Tags heartbeat
// @Accept json // @Accept json
// @Param heartbeat body models.Heartbeat true "A heartbeat" // @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Success 201 // @Success 201
// @Router /heartbeat [post] // @Router /heartbeat [post]
@ -60,15 +68,18 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
} }
var heartbeats []*models.Heartbeat var heartbeats []*models.Heartbeat
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent")) heartbeats, err = h.tryParseBulk(r)
machineName := r.Header.Get("X-Machine-Name") if err != nil {
heartbeats, err = h.tryParseSingle(r)
dec := json.NewDecoder(r.Body) if err != nil {
if err := dec.Decode(&heartbeats); err != nil {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
return return
} }
}
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
machineName := r.Header.Get("X-Machine-Name")
for _, hb := range heartbeats { for _, hb := range heartbeats {
hb.OperatingSystem = opSys hb.OperatingSystem = opSys
@ -103,12 +114,47 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
} }
} }
defer func() {}()
utils.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats))) 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) // 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 // to make the cli consider all heartbeats to having been successfully saved
// response looks like: { "responses": [ [ { "data": {...} }, 201 ], ... ] } // response looks like: { "responses": [ [ null, 201 ], ... ] }
// this was probably a temporary bug at wakatime, responses actually looks like so: https://pastr.de/p/nyf6kj2e6843fbw4xkj4h4pj
// TODO: adapt response format some time
// however, wakatime-cli is still able to parse the response (see https://github.com/wakatime/wakatime-cli/blob/c2076c0e1abc1449baf5b7ac7db391b06041c719/pkg/api/heartbeat.go#L127), so no urgent need for action
func constructSuccessResponse(n int) *heartbeatResponseVm { func constructSuccessResponse(n int) *heartbeatResponseVm {
responses := make([][]interface{}, n) responses := make([][]interface{}, n)
@ -123,3 +169,75 @@ func constructSuccessResponse(n int) *heartbeatResponseVm {
Responses: responses, 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 a new heartbeat
// @ID post-heartbeat-4
// @Tags heartbeat
// @Accept json
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Security ApiKeyAuth
// @Success 201
// @Router /users/{user}/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 /heartbeats [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 /v1/users/{user}/heartbeats.bulk [post]
func (h *HeartbeatApiHandler) postAlias5() {}
// @Summary Push new heartbeats
// @ID post-heartbeat-7
// @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) postAlias6() {}
// @Summary Push new heartbeats
// @ID post-heartbeat-8
// @Tags heartbeat
// @Accept json
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Security ApiKeyAuth
// @Success 201
// @Router /users/{user}/heartbeats.bulk [post]
func (h *HeartbeatApiHandler) postAlias7() {}

View File

@ -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-7",
"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": { "/compat/wakatime/v1/users/{user}/projects": {
"get": { "get": {
"security": [ "security": [
@ -361,7 +430,7 @@ var doc = `{
"operationId": "post-heartbeat", "operationId": "post-heartbeat",
"parameters": [ "parameters": [
{ {
"description": "A heartbeat", "description": "A single heartbeat",
"name": "heartbeat", "name": "heartbeat",
"in": "body", "in": "body",
"required": true, "required": true,
@ -377,6 +446,42 @@ var doc = `{
} }
} }
}, },
"/heartbeats": {
"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": ""
}
}
}
},
"/summary": { "/summary": {
"get": { "get": {
"security": [ "security": [
@ -441,6 +546,144 @@ var doc = `{
} }
} }
} }
},
"/users/{user}/heartbeats": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push a new heartbeat",
"operationId": "post-heartbeat-4",
"parameters": [
{
"description": "A single heartbeat",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/users/{user}/heartbeats.bulk": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push new heartbeats",
"operationId": "post-heartbeat-8",
"parameters": [
{
"description": "Multiple heartbeats",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Heartbeat"
}
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/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-6",
"parameters": [
{
"description": "Multiple heartbeats",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Heartbeat"
}
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
} }
}, },
"definitions": { "definitions": {
@ -506,6 +749,12 @@ var doc = `{
"format": "date", "format": "date",
"example": "2006-01-02 15:04:05.000" "example": "2006-01-02 15:04:05.000"
}, },
"labels": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"languages": { "languages": {
"type": "array", "type": "array",
"items": { "items": {

View File

@ -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-7",
"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": { "/compat/wakatime/v1/users/{user}/projects": {
"get": { "get": {
"security": [ "security": [
@ -345,7 +414,7 @@
"operationId": "post-heartbeat", "operationId": "post-heartbeat",
"parameters": [ "parameters": [
{ {
"description": "A heartbeat", "description": "A single heartbeat",
"name": "heartbeat", "name": "heartbeat",
"in": "body", "in": "body",
"required": true, "required": true,
@ -361,6 +430,42 @@
} }
} }
}, },
"/heartbeats": {
"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": ""
}
}
}
},
"/summary": { "/summary": {
"get": { "get": {
"security": [ "security": [
@ -425,6 +530,144 @@
} }
} }
} }
},
"/users/{user}/heartbeats": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push a new heartbeat",
"operationId": "post-heartbeat-4",
"parameters": [
{
"description": "A single heartbeat",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/users/{user}/heartbeats.bulk": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push new heartbeats",
"operationId": "post-heartbeat-8",
"parameters": [
{
"description": "Multiple heartbeats",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Heartbeat"
}
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/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-6",
"parameters": [
{
"description": "Multiple heartbeats",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Heartbeat"
}
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
} }
}, },
"definitions": { "definitions": {
@ -490,6 +733,12 @@
"format": "date", "format": "date",
"example": "2006-01-02 15:04:05.000" "example": "2006-01-02 15:04:05.000"
}, },
"labels": {
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"languages": { "languages": {
"type": "array", "type": "array",
"items": { "items": {

View File

@ -43,6 +43,10 @@ definitions:
example: "2006-01-02 15:04:05.000" example: "2006-01-02 15:04:05.000"
format: date format: date
type: string type: string
labels:
items:
$ref: '#/definitions/models.SummaryItem'
type: array
languages: languages:
items: items:
$ref: '#/definitions/models.SummaryItem' $ref: '#/definitions/models.SummaryItem'
@ -408,6 +412,48 @@ paths:
summary: Retrieve summary for all time summary: Retrieve summary for all time
tags: tags:
- wakatime - 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-7
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: /compat/wakatime/v1/users/{user}/projects:
get: get:
description: Mimics https://wakatime.com/developers#projects description: Mimics https://wakatime.com/developers#projects
@ -540,7 +586,7 @@ paths:
- application/json - application/json
operationId: post-heartbeat operationId: post-heartbeat
parameters: parameters:
- description: A heartbeat - description: A single heartbeat
in: body in: body
name: heartbeat name: heartbeat
required: true required: true
@ -554,6 +600,28 @@ paths:
summary: Push a new heartbeat summary: Push a new heartbeat
tags: tags:
- heartbeat - heartbeat
/heartbeats:
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
/summary: /summary:
get: get:
operationId: get-summary operationId: get-summary
@ -599,6 +667,90 @@ paths:
summary: Retrieve a summary summary: Retrieve a summary
tags: tags:
- summary - summary
/users/{user}/heartbeats:
post:
consumes:
- application/json
operationId: post-heartbeat-4
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
/users/{user}/heartbeats.bulk:
post:
consumes:
- application/json
operationId: post-heartbeat-8
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
/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-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
securityDefinitions: securityDefinitions:
ApiKeyAuth: ApiKeyAuth:
in: header in: header

View File

@ -299,7 +299,7 @@
"header": [], "header": [],
"body": { "body": {
"mode": "raw", "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\": {{tsNowMinus1Min}}\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\": {{tsNowMinus2Min}}\n}]", "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\": {{ts1}}\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\": {{ts3}}\n}]",
"options": { "options": {
"raw": { "raw": {
"language": "json" "language": "json"
@ -326,11 +326,18 @@
"listen": "test", "listen": "test",
"script": { "script": {
"exec": [ "exec": [
"pm.test(\"Status code is 400\", function () {", "pm.test(\"Status code is 201\", function () {",
" // only check if endpoint is present (non-404), 400 is fine because invalid body sent", " pm.response.to.have.status(201);",
" pm.response.to.have.status(400);",
"});", "});",
"" "",
"pm.test(\"Response body is correct\", function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.responses.length).to.eql(2);",
" pm.expect(jsonData.responses[0].length).to.eql(2);",
" pm.expect(jsonData.responses[1].length).to.eql(2);",
" pm.expect(jsonData.responses[0][1]).to.eql(201);",
" pm.expect(jsonData.responses[1][1]).to.eql(201);",
"});"
], ],
"type": "text/javascript" "type": "text/javascript"
} }
@ -354,7 +361,7 @@
"header": [], "header": [],
"body": { "body": {
"mode": "raw", "mode": "raw",
"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\": {{ts1}}\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\": {{ts2}}\n}]",
"options": { "options": {
"raw": { "raw": {
"language": "json" "language": "json"
@ -384,11 +391,18 @@
"listen": "test", "listen": "test",
"script": { "script": {
"exec": [ "exec": [
"pm.test(\"Status code is 400\", function () {", "pm.test(\"Status code is 201\", function () {",
" // only check if endpoint is present (non-404), 400 is fine because invalid body sent", " pm.response.to.have.status(201);",
" pm.response.to.have.status(400);",
"});", "});",
"" "",
"pm.test(\"Response body is correct\", function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.responses.length).to.eql(2);",
" pm.expect(jsonData.responses[0].length).to.eql(2);",
" pm.expect(jsonData.responses[1].length).to.eql(2);",
" pm.expect(jsonData.responses[0][1]).to.eql(201);",
" pm.expect(jsonData.responses[1][1]).to.eql(201);",
"});"
], ],
"type": "text/javascript" "type": "text/javascript"
} }
@ -412,7 +426,7 @@
"header": [], "header": [],
"body": { "body": {
"mode": "raw", "mode": "raw",
"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\": {{ts1}}\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\": {{ts2}}\n}]",
"options": { "options": {
"raw": { "raw": {
"language": "json" "language": "json"
@ -437,6 +451,376 @@
}, },
"response": [] "response": []
}, },
{
"name": "Create heartbeats (alt 3)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
"",
"pm.test(\"Response body is correct\", function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.responses.length).to.eql(2);",
" pm.expect(jsonData.responses[0].length).to.eql(2);",
" pm.expect(jsonData.responses[1].length).to.eql(2);",
" pm.expect(jsonData.responses[0][1]).to.eql(201);",
" pm.expect(jsonData.responses[1][1]).to.eql(201);",
"});"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableCookies": true
},
"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\": {{ts1}}\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\": {{ts2}}\n}]",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE_URL}}/api/users/current/heartbeats.bulk",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"users",
"current",
"heartbeats.bulk"
]
}
},
"response": []
},
{
"name": "Create heartbeats (alt 4, single)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
"",
"pm.test(\"Response body is correct\", function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.responses.length).to.eql(1);",
" pm.expect(jsonData.responses[0].length).to.eql(2);",
" pm.expect(jsonData.responses[0][1]).to.eql(201);",
"});"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableCookies": true
},
"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\": {{ts1}}\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE_URL}}/api/heartbeat",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"heartbeat"
]
}
},
"response": []
},
{
"name": "Create heartbeats (alt 5, single)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
"",
"pm.test(\"Response body is correct\", function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.responses.length).to.eql(1);",
" pm.expect(jsonData.responses[0].length).to.eql(2);",
" pm.expect(jsonData.responses[0][1]).to.eql(201);",
"});"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableCookies": true
},
"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\": {{ts1}}\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE_URL}}/api/v1/users/current/heartbeats",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"v1",
"users",
"current",
"heartbeats"
]
}
},
"response": []
},
{
"name": "Create heartbeats (alt 6, single)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
"",
"pm.test(\"Response body is correct\", function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.responses.length).to.eql(1);",
" pm.expect(jsonData.responses[0].length).to.eql(2);",
" pm.expect(jsonData.responses[0][1]).to.eql(201);",
"});"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableCookies": true
},
"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\": {{ts1}}\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"compat",
"wakatime",
"v1",
"users",
"current",
"heartbeats"
]
}
},
"response": []
},
{
"name": "Create heartbeats (alt 7, single)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
"",
"pm.test(\"Response body is correct\", function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.responses.length).to.eql(1);",
" pm.expect(jsonData.responses[0].length).to.eql(2);",
" pm.expect(jsonData.responses[0][1]).to.eql(201);",
"});"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableCookies": true
},
"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\": {{ts1}}\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE_URL}}/api/users/current/heartbeats",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"users",
"current",
"heartbeats"
]
}
},
"response": []
},
{
"name": "Create heartbeats (non-matching)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 404\", function () {",
" pm.response.to.have.status(404);",
"});"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableCookies": true
},
"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\": {{ts1}}\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE_URL}}/api/v2/users/current/heartbeats",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"v2",
"users",
"current",
"heartbeats"
]
}
},
"response": []
},
{ {
"name": "Create heartbeats (unauthorized)", "name": "Create heartbeats (unauthorized)",
"event": [ "event": [
@ -732,8 +1116,8 @@
"", "",
"pm.test(\"Correct dates\", function () {", "pm.test(\"Correct dates\", function () {",
" const jsonData = pm.response.json();", " const jsonData = pm.response.json();",
" pm.expect(moment(jsonData.from).unix()).to.gt(moment(pm.variables.get('tsStartOfDayDate')).unix())", " pm.expect(moment(jsonData.from).unix()).to.gte(moment(pm.variables.get('tsStartOfDayDate')).unix())",
" pm.expect(moment(jsonData.to).unix()).to.gt(moment(pm.variables.get('tsEndOfDayDate')).unix())", " pm.expect(moment(jsonData.to).unix()).to.gte(moment(pm.variables.get('tsEndOfDayDate')).unix())",
"});", "});",
"" ""
], ],
@ -2469,7 +2853,10 @@
"pm.variables.set('tsEndOfTomorrowIso', endOfTomorrow.toISOString())", "pm.variables.set('tsEndOfTomorrowIso', endOfTomorrow.toISOString())",
"pm.variables.set('tsStartOfDayDate', startOfDay.format('YYYY-MM-DD'))", "pm.variables.set('tsStartOfDayDate', startOfDay.format('YYYY-MM-DD'))",
"pm.variables.set('tsEndOfDayDate', endOfDay.format('YYYY-MM-DD'))", "pm.variables.set('tsEndOfDayDate', endOfDay.format('YYYY-MM-DD'))",
"pm.variables.set('tsEndOfTomorrowDate', endOfTomorrow.format('YYYY-MM-DD'))" "pm.variables.set('tsEndOfTomorrowDate', endOfTomorrow.format('YYYY-MM-DD'))",
"pm.variables.set('ts1', now.startOf('hour').format('x') / 1000)",
"pm.variables.set('ts2', now.startOf('hour').add(1, 'm').format('x') / 1000)",
"pm.variables.set('ts3', now.startOf('hour').add(2, 'm').format('x') / 1000)"
] ]
} }
}, },

View File

@ -1 +1 @@
1.28.2 1.29.1