Skip to content

Commit

Permalink
Add support for submitting workouts via API
Browse files Browse the repository at this point in the history
This adds support for FitoTrack to automatically upload workouts via
HTTP POST. We had to add authentication via query parameter, since
FitoTrack did not support Header or BasicAuth authentication.

We also now have a README section that can be extended.

Fixes #64

Signed-off-by: Jo Vandeginste <Jo.Vandeginste@kuleuven.be>
  • Loading branch information
jovandeginste committed Apr 15, 2024
1 parent 2f9e16a commit 10f5926
Show file tree
Hide file tree
Showing 14 changed files with 289 additions and 29 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,18 @@ If no users are in the database (eg. when starting with an empty database), a
default `admin` user is created with password `admin`. You should change this
password in a production environment.

## API usage

The API is documented using [swagger](https://swagger.io/). You must enable API access for your user, and copy the API key. You can use the API key as a query parameter (`api-key=${API_KEY}`) or as a header (`Authorization: Bearer ${API_KEY}`).

You can configure some tools to automatically upload files to Workout Tracker, using the `POST /api/v1/import/$program` API endpoint.

### FitoTrack

Read [their documentation](https://codeberg.org/jannis/FitoTrack/wiki/Auto-Export) before you continue.

The path to POST to is: `/api/v1/import/fitotrack?api-key=${API_KEY}`

## Development

### Build and run it yourself
Expand Down
55 changes: 55 additions & 0 deletions docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,61 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/import/{program}": {
"post": {
"produces": [
"application/json"
],
"summary": "Import a workout",
"parameters": [
{
"type": "string",
"description": "Program that generates the workout file",
"name": "program",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/app.APIResponse"
},
{
"type": "object",
"properties": {
"result": {
"$ref": "#/definitions/database.Workout"
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/app.APIResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/app.APIResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/app.APIResponse"
}
}
}
}
},
"/records": {
"get": {
"produces": [
Expand Down
55 changes: 55 additions & 0 deletions docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,61 @@
},
"basePath": "/api/v1",
"paths": {
"/import/{program}": {
"post": {
"produces": [
"application/json"
],
"summary": "Import a workout",
"parameters": [
{
"type": "string",
"description": "Program that generates the workout file",
"name": "program",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/app.APIResponse"
},
{
"type": "object",
"properties": {
"result": {
"$ref": "#/definitions/database.Workout"
}
}
}
]
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/app.APIResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/app.APIResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/app.APIResponse"
}
}
}
}
},
"/records": {
"get": {
"produces": [
Expand Down
33 changes: 33 additions & 0 deletions docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,39 @@ info:
title: Workout Tracker
version: "1.0"
paths:
/import/{program}:
post:
parameters:
- description: Program that generates the workout file
in: path
name: program
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/app.APIResponse'
- properties:
result:
$ref: '#/definitions/database.Workout'
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/app.APIResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/app.APIResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/app.APIResponse'
summary: Import a workout
/records:
get:
parameters:
Expand Down
82 changes: 61 additions & 21 deletions pkg/app/api_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strconv"

"github.com/jovandeginste/workout-tracker/pkg/database"
"github.com/jovandeginste/workout-tracker/pkg/importers"
echojwt "github.com/labstack/echo-jwt/v4"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
Expand Down Expand Up @@ -37,11 +38,27 @@ func (a *App) apiRoutes(e *echo.Group) {
return c.JSON(http.StatusForbidden, "Not authorized")
},
Skipper: func(ctx echo.Context) bool {
return ctx.Request().Header.Get("Authorization") != ""
if ctx.Request().Header.Get("Authorization") != "" {
return true
}

return ctx.Request().URL.Query().Get("api-key") != ""
},
SuccessHandler: a.ValidateUserMiddleware,
}))
apiGroup.Use(a.ValidateAPIKeyMiddleware())
apiGroup.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
Validator: a.ValidateAPIKeyMiddleware,
KeyLookup: "query:api-key",
Skipper: func(ctx echo.Context) bool {
return ctx.Request().URL.Query().Get("api-key") == ""
},
}))
apiGroup.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
Validator: a.ValidateAPIKeyMiddleware,
Skipper: func(ctx echo.Context) bool {
return ctx.Request().Header.Get("Authorization") == ""
},
}))

apiGroup.GET("/whoami", a.apiWhoamiHandler).Name = "api-whoami"
apiGroup.GET("/workouts", a.apiWorkoutsHandler).Name = "api-workouts"
Expand All @@ -50,30 +67,23 @@ func (a *App) apiRoutes(e *echo.Group) {
apiGroup.GET("/statistics", a.apiStatisticsHandler).Name = "api-statistics"
apiGroup.GET("/totals", a.apiTotalsHandler).Name = "api-totals"
apiGroup.GET("/records", a.apiRecordsHandler).Name = "api-records"
apiGroup.POST("/import/:program", a.apiImportHandler).Name = "api-import"
}

func (a *App) ValidateAPIKeyMiddleware() echo.MiddlewareFunc {
return middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
Validator: func(key string, c echo.Context) (bool, error) {
u, err := database.GetUserByAPIKey(a.db, key)
if err != nil {
return false, ErrInvalidAPIKey
}
func (a *App) ValidateAPIKeyMiddleware(key string, c echo.Context) (bool, error) {
u, err := database.GetUserByAPIKey(a.db, key)
if err != nil {
return false, ErrInvalidAPIKey
}

if !u.IsActive() || !u.Profile.APIActive {
return false, ErrInvalidAPIKey
}
if !u.IsActive() || !u.Profile.APIActive {
return false, ErrInvalidAPIKey
}

c.Set("user_info", u)
c.Set("user_language", u.Profile.Language)
c.Set("user_totals_show", u.Profile.TotalsShow)
c.Set("user_info", u)
c.Set("user_language", u.Profile.Language)

return true, nil
},
Skipper: func(ctx echo.Context) bool {
return ctx.Request().Header.Get("Authorization") == ""
},
})
return true, nil
}

// apiWhoamiHandler shows current user's information
Expand Down Expand Up @@ -275,6 +285,36 @@ func (a *App) apiWorkoutHandler(c echo.Context) error {
return c.JSON(http.StatusOK, resp)
}

// apiImportHandler imports a workout
// @Summary Import a workout
// @Param program path string true "Program that generates the workout file"
// @Produce json
// @Success 200 {object} APIResponse{result=database.Workout}
// @Failure 400 {object} APIResponse
// @Failure 404 {object} APIResponse
// @Failure 500 {object} APIResponse
// @Router /import/{program} [post]
func (a *App) apiImportHandler(c echo.Context) error {
resp := APIResponse{}

program := c.Param("program")
a.logger.Info("Importing with program: " + program)

file, err := importers.Import(program, c.Request().Header, c.Request().Body)
if err != nil {
return a.renderAPIError(c, resp, err)
}

w, addErr := a.getCurrentUser(c).AddWorkout(a.db, database.WorkoutType(file.Type), file.Notes, file.Filename, file.Content)
if addErr != nil {
return a.renderAPIError(c, resp, addErr)
}

resp.Results = w

return c.JSON(http.StatusOK, resp)
}

func (a *App) renderAPIError(c echo.Context, resp APIResponse, err error) error {
resp.Errors = append(resp.Errors, err.Error())

Expand Down
1 change: 0 additions & 1 deletion pkg/app/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ func (a *App) setUser(c echo.Context) error {

c.Set("user_info", dbUser)
c.Set("user_language", dbUser.Profile.Language)
c.Set("user_totals_show", dbUser.Profile.TotalsShow)

return nil
}
Expand Down
5 changes: 0 additions & 5 deletions pkg/converters/tcx.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"encoding/xml"
"fmt"

"github.com/davecgh/go-spew/spew"
"github.com/galeone/tcx"
"github.com/tkrajina/gpxgo/gpx"
)
Expand Down Expand Up @@ -53,9 +52,5 @@ func ParseTCX(tcxFile []byte) (*gpx.GPX, error) {
}
}

spew.Dump(g.Tracks[0].Duration())
// spew.Dump(g)
// panic("stop")

return g, nil
}
3 changes: 3 additions & 0 deletions pkg/database/workouts.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ func NewWorkout(u *User, workoutType WorkoutType, notes string, filename string,
}

data := gpxAsMapData(gpxContent)
if filename == "" {
filename = data.Name + ".gpx"
}

h := sha256.New()
h.Write(content)
Expand Down
31 changes: 31 additions & 0 deletions pkg/importers/fitotrack.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package importers

import (
"fmt"
"io"
"net/http"
)

func importFitotrack(headers http.Header, body io.ReadCloser) (*Content, error) {
if t := headers.Get("FitoTrack-Type"); t != "workout-gpx" {
return nil, fmt.Errorf("unsupported FitoTrack-Type: %s", t)
}

wt := headers.Get("FitoTrack-Workout-Type")
wn := headers.Get("FitoTrack-Comment")

defer body.Close()

b, err := io.ReadAll(body)
if err != nil {
return nil, err
}

g := &Content{
Content: b,
Type: wt,
Notes: wn,
}

return g, nil
}
25 changes: 25 additions & 0 deletions pkg/importers/importer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package importers

import (
"errors"
"fmt"
"io"
"net/http"
)

var ErrUnsupportedProgram = errors.New("unsupported program")

type Content struct {
Content []byte
Filename string
Notes string
Type string
}

func Import(program string, headers http.Header, body io.ReadCloser) (*Content, error) {
if program == "fitotrack" {
return importFitotrack(headers, body)
}

return nil, fmt.Errorf("%w: %s", ErrUnsupportedProgram, program)
}
Loading

0 comments on commit 10f5926

Please sign in to comment.