diff --git a/app/controllers/openapi_controller.rb b/app/controllers/openapi_controller.rb
new file mode 100644
index 000000000..115125b4c
--- /dev/null
+++ b/app/controllers/openapi_controller.rb
@@ -0,0 +1,47 @@
+# Publishes the OpenAPI description of Hackatime's public API at the
+# conventional, machine-discoverable URLs (`/openapi.json`, `/openapi.yaml`
+# and their `/api` aliases). The Scalar reference at `/api-docs` is the human
+# entry point; these endpoints are what API clients, SDK generators and agents
+# fetch. Both formats render the same document that `rswag` generates into
+# `swagger/v1/swagger.yaml`, so there is a single source of truth.
+class OpenapiController < ApplicationController
+ SPEC_PATH = Rails.root.join("swagger", "v1", "swagger.yaml").freeze
+ CACHE_MAX_AGE = 1.hour
+
+ class << self
+ def json_document
+ @json_document ||= "#{JSON.pretty_generate(parsed_document)}\n"
+ end
+
+ def yaml_document
+ @yaml_document ||= SPEC_PATH.read
+ end
+
+ private
+
+ def parsed_document
+ YAML.safe_load(yaml_document, aliases: true)
+ end
+ end
+
+ def show_json
+ render_spec self.class.json_document, "application/json"
+ end
+
+ def show_yaml
+ # RFC 9512 registers `application/yaml` as the media type for YAML.
+ render_spec self.class.yaml_document, "application/yaml"
+ end
+
+ private
+
+ def render_spec(body, content_type)
+ # Overrides ApplicationController's `no-store` default: the document is
+ # public, identical for every caller and only changes on deploy.
+ response.headers["Cache-Control"] = "public, max-age=#{CACHE_MAX_AGE.to_i}"
+ # Browser-based API consoles need to fetch the spec cross-origin.
+ response.headers["Access-Control-Allow-Origin"] = "*"
+
+ render plain: body, content_type: content_type
+ end
+end
diff --git a/app/views/layouts/inertia.html.erb b/app/views/layouts/inertia.html.erb
index bbdd9fc75..3771beffa 100644
--- a/app/views/layouts/inertia.html.erb
+++ b/app/views/layouts/inertia.html.erb
@@ -12,6 +12,8 @@
+
+
diff --git a/config/routes.rb b/config/routes.rb
index 43371fdcc..d7b069b76 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -22,6 +22,15 @@ def matches?(request)
get "api-docs", to: "api_docs#show", as: :api_docs
get "api-docs/admin", to: "api_docs#admin", as: :admin_api_docs
mount Rswag::Api::Engine => "/api-docs"
+
+ # Machine-readable description of the public API. /openapi.json is the
+ # canonical location; the others are conventional aliases that API clients
+ # and agents probe for.
+ get "openapi.json", to: "openapi#show_json", as: :openapi, format: false
+ get "openapi.yaml", to: "openapi#show_yaml", as: :openapi_yaml, format: false
+ get "api/openapi.json", to: "openapi#show_json", as: :api_openapi, format: false
+ get "api/openapi.yaml", to: "openapi#show_yaml", as: :api_openapi_yaml, format: false
+
defaults export: true do
use_doorkeeper do
controllers authorizations: "custom_doorkeeper/authorizations"
diff --git a/docs/index.mdx b/docs/index.mdx
index 26eb0fbe9..0105f19a1 100644
--- a/docs/index.mdx
+++ b/docs/index.mdx
@@ -23,7 +23,7 @@ Get Hackatime running in your editor, customise your setup or build an integrati
Build an OAuth integration.
`}>
- Browse Hackatime endpoints.
+ Browse Hackatime endpoints, or fetch the OpenAPI spec from `/openapi.json`.
`}>
Solve common setup problems.
diff --git a/public/robots.txt b/public/robots.txt
index 43ca6cd7f..6cefd499b 100644
--- a/public/robots.txt
+++ b/public/robots.txt
@@ -5,6 +5,13 @@ Allow: /
Allow: /docs
Allow: /docs/*
Allow: /leaderboards
+Allow: /api-docs
+
+# Machine-readable API description
+Allow: /openapi.json
+Allow: /openapi.yaml
+Allow: /api/openapi.json
+Allow: /api/openapi.yaml
# Disallow private/internal pages
Disallow: /my/
diff --git a/spec/requests/api/hackatime/v1/compatibility_spec.rb b/spec/requests/api/hackatime/v1/compatibility_spec.rb
index 8b81bdb96..6ec8dd118 100644
--- a/spec/requests/api/hackatime/v1/compatibility_spec.rb
+++ b/spec/requests/api/hackatime/v1/compatibility_spec.rb
@@ -285,8 +285,8 @@
produces 'application/json'
parameter name: :id, in: :path, type: :string, description: 'User ID or "current" (recommended). The authenticated user is resolved from the API token.'
- parameter name: :start, in: :query, type: :string, format: :date, required: true, description: 'Inclusive start date in YYYY-MM-DD format.'
- parameter name: :end, in: :query, type: :string, format: :date, required: true, description: 'Inclusive end date in YYYY-MM-DD format. The range may contain at most 366 days.'
+ parameter name: :start, in: :query, schema: { type: :string, format: :date }, required: true, description: 'Inclusive start date in YYYY-MM-DD format.'
+ parameter name: :end, in: :query, schema: { type: :string, format: :date }, required: true, description: 'Inclusive end date in YYYY-MM-DD format. The range may contain at most 366 days.'
parameter name: :project, in: :query, type: :string, required: false, description: 'Only include activity for this project.'
parameter name: :timezone, in: :query, type: :string, required: false, description: "Timezone used to segment days. Defaults to the user's timezone."
diff --git a/spec/requests/api/openapi_spec.rb b/spec/requests/api/openapi_spec.rb
new file mode 100644
index 000000000..4a812603b
--- /dev/null
+++ b/spec/requests/api/openapi_spec.rb
@@ -0,0 +1,57 @@
+require 'swagger_helper'
+
+RSpec.describe 'Api::Openapi', type: :request do
+ path '/openapi.json' do
+ get('Fetch the OpenAPI description of this API') do
+ tags 'Discovery'
+ description <<~DESC
+ Returns this document. No authentication is required.
+
+ `/api/openapi.json` is an alias for the same resource, and the YAML
+ representation is available at `/openapi.yaml` (aliased at
+ `/api/openapi.yaml`).
+ DESC
+ security []
+ produces 'application/json'
+
+ response(200, 'successful') do
+ schema type: :object,
+ description: 'An OpenAPI 3.0 document describing the Hackatime API.',
+ properties: {
+ openapi: { type: :string, example: '3.0.1' },
+ info: { type: :object },
+ paths: { type: :object },
+ components: { type: :object },
+ servers: { type: :array, items: { type: :object } }
+ },
+ required: %w[openapi info paths]
+
+ run_test! do |response|
+ body = JSON.parse(response.body)
+ expect(body['openapi']).to eq('3.0.1')
+ expect(body.dig('info', 'title')).to eq('Hackatime API')
+ expect(body['paths']).to be_a(Hash)
+ end
+ end
+ end
+ end
+
+ path '/openapi.yaml' do
+ get('Fetch the OpenAPI description of this API as YAML') do
+ tags 'Discovery'
+ description 'The YAML representation of `/openapi.json`. No authentication is required.'
+ security []
+ produces 'application/yaml'
+
+ response(200, 'successful') do
+ schema type: :string, description: 'An OpenAPI 3.0 document, serialised as YAML.'
+
+ run_test! do |response|
+ body = YAML.safe_load(response.body, aliases: true)
+ expect(body['openapi']).to eq('3.0.1')
+ expect(body.dig('info', 'title')).to eq('Hackatime API')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/summary_spec.rb b/spec/requests/api/summary_spec.rb
index 125f950d6..88e0f60f1 100644
--- a/spec/requests/api/summary_spec.rb
+++ b/spec/requests/api/summary_spec.rb
@@ -8,6 +8,7 @@
'This endpoint does NOT authenticate any API token: access is gated solely by the target ' \
'user (identified by user_id/user) having allow_public_stats_lookup enabled. No caller ' \
'credentials are required or verified.'
+ security []
produces 'application/json'
parameter name: :start, in: :query, schema: { type: :string, format: :date }, description: 'Start date (YYYY-MM-DD). Requires "end"/"to" to be set as well to form an explicit range.'
diff --git a/spec/requests/api/v1/badges_spec.rb b/spec/requests/api/v1/badges_spec.rb
index 7f94b239a..aea3f6c12 100644
--- a/spec/requests/api/v1/badges_spec.rb
+++ b/spec/requests/api/v1/badges_spec.rb
@@ -42,6 +42,7 @@ def log_time(user, project, seconds: 600)
Any additional query parameters not consumed below (e.g. `style`, `logo`,
`logoColor`, `labelColor`) are passed straight through to shields.io.
DESC
+ security []
parameter name: :user_id, in: :path, type: :string, required: true,
description: 'User identifier: Slack UID, username, or numeric internal ID.'
diff --git a/spec/requests/api/v1/currently_hacking_spec.rb b/spec/requests/api/v1/currently_hacking_spec.rb
index 820b44ef5..05c0e951a 100644
--- a/spec/requests/api/v1/currently_hacking_spec.rb
+++ b/spec/requests/api/v1/currently_hacking_spec.rb
@@ -10,6 +10,7 @@
on (if its repo mapping is not archived). The endpoint is public (no
authentication required) and the result is cached for 5 minutes.
DESC
+ security []
produces 'application/json'
response(200, 'successful') do
diff --git a/spec/requests/api/v1/leaderboards_spec.rb b/spec/requests/api/v1/leaderboards_spec.rb
index 8c6a72045..1959e310d 100644
--- a/spec/requests/api/v1/leaderboards_spec.rb
+++ b/spec/requests/api/v1/leaderboards_spec.rb
@@ -14,6 +14,7 @@
get('Get daily leaderboard (Alias)') do
tags 'Leaderboard'
description 'Alias for /api/v1/leaderboard/daily. Returns the daily leaderboard. Public, no authentication required.'
+ security []
produces 'application/json'
response(200, 'successful', document: false) do
@@ -55,6 +56,7 @@
get('Get daily leaderboard') do
tags 'Leaderboard'
description 'Returns the daily leaderboard of coding time. Public, no authentication required. The leaderboard is cached and regenerated periodically.'
+ security []
produces 'application/json'
response(200, 'successful') do
@@ -96,6 +98,7 @@
get('Get weekly leaderboard') do
tags 'Leaderboard'
description 'Returns the weekly leaderboard of coding time (last 7 days). Public, no authentication required.'
+ security []
produces 'application/json'
response(200, 'successful') do
diff --git a/spec/requests/api/v1/stats_spec.rb b/spec/requests/api/v1/stats_spec.rb
index 99d1ce25a..8d4f65aaf 100644
--- a/spec/requests/api/v1/stats_spec.rb
+++ b/spec/requests/api/v1/stats_spec.rb
@@ -66,6 +66,7 @@
get('Get newly-banned user counts') do
tags 'Stats'
description 'Returns the number of distinct users whose trust level was newly set to "red" (banned/convicted) over the last day, week, and month.'
+ security []
produces 'application/json'
response(200, 'successful') do
@@ -89,6 +90,7 @@
get('Get user heartbeat spans') do
tags 'Stats'
description 'Returns heartbeat spans for a user, useful for visualizations. Accessible anonymously when the target user has public stats lookup enabled; otherwise the requester must be the user (authenticated via the User API Key).'
+ security [ {}, { Bearer: [] }, { ApiKeyAuth: [] } ]
produces 'application/json'
parameter name: :username, in: :path, type: :string, description: 'Username, Slack ID, or User ID. The literal value "my" resolves the user from the Authorization Bearer token.'
@@ -160,6 +162,7 @@
get('Get user trust factor') do
tags 'Stats'
description 'Returns the (masked) trust level and value for a user. Only the public-facing levels are ever returned: blue (0, unscored), red (1, convicted), green (2, trusted). The internal "yellow" (suspected) level is masked to blue and never exposed.'
+ security [ {}, { Bearer: [] }, { ApiKeyAuth: [] } ]
produces 'application/json'
parameter name: :username, in: :path, type: :string, description: 'Username, Slack ID, or User ID'
@@ -187,6 +190,7 @@
get('Get user project names') do
tags 'Stats'
description 'Returns a list of project names for a user from the last 30 days. Accessible anonymously when the target user has public stats lookup enabled.'
+ security [ {}, { Bearer: [] }, { ApiKeyAuth: [] } ]
produces 'application/json'
parameter name: :username, in: :path, type: :string, description: 'Username, Slack ID, or User ID'
@@ -225,6 +229,7 @@
get('Get user project details') do
tags 'Stats'
description 'Returns details for a specific project. Accessible anonymously when the target user has public stats lookup enabled.'
+ security [ {}, { Bearer: [] }, { ApiKeyAuth: [] } ]
produces 'application/json'
parameter name: :username, in: :path, type: :string, description: 'Username, Slack ID, or User ID'
@@ -299,6 +304,7 @@
get('Get details for multiple projects') do
tags 'Stats'
description 'Returns details for multiple projects, or all projects in a time range. Accessible anonymously when the target user has public stats lookup enabled.'
+ security [ {}, { Bearer: [] }, { ApiKeyAuth: [] } ]
produces 'application/json'
parameter name: :username, in: :path, type: :string, description: 'Username, Slack ID, or User ID'
diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb
index 59d8d2bc2..1ba6a84c6 100644
--- a/spec/swagger_helper.rb
+++ b/spec/swagger_helper.rb
@@ -300,11 +300,23 @@
info: {
title: 'Hackatime API',
version: 'v1',
- description: <<~DESC
+ description: <<~DESC,
Hackatime's API gives access to coding activity data.
We support the WakaTime spec, allowing you to use existing plugins and tools.
DESC
+ contact: {
+ name: 'Hack Club',
+ url: 'https://github.com/hackclub/hackatime'
+ },
+ license: {
+ name: 'MIT',
+ url: 'https://github.com/hackclub/hackatime/blob/main/LICENSE'
+ }
+ },
+ externalDocs: {
+ description: 'Hackatime documentation',
+ url: 'https://hackatime.hackclub.com/docs'
},
paths: {},
components: {
@@ -344,12 +356,24 @@
info: {
title: 'Hackatime Admin API',
version: 'v1',
- description: <<~DESC
+ description: <<~DESC,
Admin and internal endpoints for Hackatime.
Admin endpoints require an Admin API Key or an OAuth access token with the `admin` scope.
Internal endpoints require an internal environment token. These endpoints are not part of the public API.
DESC
+ contact: {
+ name: 'Hack Club',
+ url: 'https://github.com/hackclub/hackatime'
+ },
+ license: {
+ name: 'MIT',
+ url: 'https://github.com/hackclub/hackatime/blob/main/LICENSE'
+ }
+ },
+ externalDocs: {
+ description: 'Hackatime documentation',
+ url: 'https://hackatime.hackclub.com/docs'
},
paths: {},
components: {
diff --git a/swagger/admin/swagger.yaml b/swagger/admin/swagger.yaml
index 968a0b8d3..6f790ba00 100644
--- a/swagger/admin/swagger.yaml
+++ b/swagger/admin/swagger.yaml
@@ -8,6 +8,15 @@ info:
Admin endpoints require an Admin API Key or an OAuth access token with the `admin` scope.
Internal endpoints require an internal environment token. These endpoints are not part of the public API.
+ contact:
+ name: Hack Club
+ url: https://github.com/hackclub/hackatime
+ license:
+ name: MIT
+ url: https://github.com/hackclub/hackatime/blob/main/LICENSE
+externalDocs:
+ description: Hackatime documentation
+ url: https://hackatime.hackclub.com/docs
paths:
"/api/admin/v1/users/{id}/visualization/quantized":
get:
diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml
index a484f3d50..58da0dced 100644
--- a/swagger/v1/swagger.yaml
+++ b/swagger/v1/swagger.yaml
@@ -7,6 +7,15 @@ info:
Hackatime's API gives access to coding activity data.
We support the WakaTime spec, allowing you to use existing plugins and tools.
+ contact:
+ name: Hack Club
+ url: https://github.com/hackclub/hackatime
+ license:
+ name: MIT
+ url: https://github.com/hackclub/hackatime/blob/main/LICENSE
+externalDocs:
+ description: Hackatime documentation
+ url: https://hackatime.hackclub.com/docs
paths:
"/api/hackatime/v1/users/{id}/heartbeats":
post:
@@ -365,19 +374,19 @@ paths:
type: string
- name: start
in: query
- format: date
- required: true
- description: Inclusive start date in YYYY-MM-DD format.
schema:
type: string
+ format: date
+ required: true
+ description: Inclusive start date in YYYY-MM-DD format.
- name: end
in: query
- format: date
+ schema:
+ type: string
+ format: date
required: true
description: Inclusive end date in YYYY-MM-DD format. The range may contain
at most 366 days.
- schema:
- type: string
- name: project
in: query
required: false
@@ -862,6 +871,60 @@ paths:
example: 0
'401':
description: unauthorized
+ "/openapi.json":
+ get:
+ summary: Fetch the OpenAPI description of this API
+ tags:
+ - Discovery
+ description: |
+ Returns this document. No authentication is required.
+
+ `/api/openapi.json` is an alias for the same resource, and the YAML
+ representation is available at `/openapi.yaml` (aliased at
+ `/api/openapi.yaml`).
+ security: []
+ responses:
+ '200':
+ description: successful
+ content:
+ application/json:
+ schema:
+ type: object
+ description: An OpenAPI 3.0 document describing the Hackatime API.
+ properties:
+ openapi:
+ type: string
+ example: 3.0.1
+ info:
+ type: object
+ paths:
+ type: object
+ components:
+ type: object
+ servers:
+ type: array
+ items:
+ type: object
+ required:
+ - openapi
+ - info
+ - paths
+ "/openapi.yaml":
+ get:
+ summary: Fetch the OpenAPI description of this API as YAML
+ tags:
+ - Discovery
+ description: The YAML representation of `/openapi.json`. No authentication is
+ required.
+ security: []
+ responses:
+ '200':
+ description: successful
+ content:
+ application/yaml:
+ schema:
+ type: string
+ description: An OpenAPI 3.0 document, serialised as YAML.
"/api/summary":
get:
summary: Get WakaTime-compatible summary
@@ -871,6 +934,7 @@ paths:
WakaTime clients. This endpoint does NOT authenticate any API token: access
is gated solely by the target user (identified by user_id/user) having allow_public_stats_lookup
enabled. No caller credentials are required or verified.'
+ security: []
parameters:
- name: start
in: query
@@ -1328,6 +1392,7 @@ paths:
Any additional query parameters not consumed below (e.g. `style`, `logo`,
`logoColor`, `labelColor`) are passed straight through to shields.io.
+ security: []
parameters:
- name: user_id
in: path
@@ -1380,6 +1445,7 @@ paths:
in the last 5 minutes, along with the project each is currently working
on (if its repo mapping is not archived). The endpoint is public (no
authentication required) and the result is cached for 5 minutes.
+ security: []
responses:
'200':
description: successful
@@ -1437,6 +1503,7 @@ paths:
- Leaderboard
description: Returns the daily leaderboard of coding time. Public, no authentication
required. The leaderboard is cached and regenerated periodically.
+ security: []
responses:
'200':
description: successful
@@ -1476,6 +1543,7 @@ paths:
- Leaderboard
description: Returns the weekly leaderboard of coding time (last 7 days). Public,
no authentication required.
+ security: []
responses:
'200':
description: successful
@@ -1696,6 +1764,7 @@ paths:
- Stats
description: Returns the number of distinct users whose trust level was newly
set to "red" (banned/convicted) over the last day, week, and month.
+ security: []
responses:
'200':
description: successful
@@ -1725,6 +1794,10 @@ paths:
description: Returns heartbeat spans for a user, useful for visualizations.
Accessible anonymously when the target user has public stats lookup enabled;
otherwise the requester must be the user (authenticated via the User API Key).
+ security:
+ - {}
+ - Bearer: []
+ - ApiKeyAuth: []
parameters:
- name: username
in: path
@@ -1809,6 +1882,10 @@ paths:
public-facing levels are ever returned: blue (0, unscored), red (1, convicted),
green (2, trusted). The internal "yellow" (suspected) level is masked to blue
and never exposed.'
+ security:
+ - {}
+ - Bearer: []
+ - ApiKeyAuth: []
parameters:
- name: username
in: path
@@ -1851,6 +1928,10 @@ paths:
- Stats
description: Returns a list of project names for a user from the last 30 days.
Accessible anonymously when the target user has public stats lookup enabled.
+ security:
+ - {}
+ - Bearer: []
+ - ApiKeyAuth: []
parameters:
- name: username
in: path
@@ -1891,6 +1972,10 @@ paths:
- Stats
description: Returns details for a specific project. Accessible anonymously
when the target user has public stats lookup enabled.
+ security:
+ - {}
+ - Bearer: []
+ - ApiKeyAuth: []
parameters:
- name: username
in: path
@@ -2000,6 +2085,10 @@ paths:
description: Returns details for multiple projects, or all projects in a time
range. Accessible anonymously when the target user has public stats lookup
enabled.
+ security:
+ - {}
+ - Bearer: []
+ - ApiKeyAuth: []
parameters:
- name: username
in: path
diff --git a/test/controllers/openapi_controller_test.rb b/test/controllers/openapi_controller_test.rb
new file mode 100644
index 000000000..c3a1026c9
--- /dev/null
+++ b/test/controllers/openapi_controller_test.rb
@@ -0,0 +1,84 @@
+require "test_helper"
+
+class OpenapiControllerTest < ActionDispatch::IntegrationTest
+ test "serves the OpenAPI document as JSON" do
+ get openapi_path
+
+ assert_response :success
+ assert_equal "application/json", response.media_type
+
+ document = JSON.parse(response.body)
+ assert_equal "3.0.1", document["openapi"]
+ assert_equal "Hackatime API", document.dig("info", "title")
+ assert document["paths"].present?, "expected the document to describe at least one path"
+ assert_includes document["paths"].keys, "/api/hackatime/v1/users/{id}/heartbeats"
+ end
+
+ test "serves the OpenAPI document as YAML" do
+ get openapi_yaml_path
+
+ assert_response :success
+ assert_equal "application/yaml", response.media_type
+ assert_equal YAML.safe_load(Rails.root.join("swagger", "v1", "swagger.yaml").read, aliases: true),
+ YAML.safe_load(response.body, aliases: true)
+ end
+
+ test "JSON and YAML representations describe the same document" do
+ get openapi_path
+ json_document = JSON.parse(response.body)
+
+ get openapi_yaml_path
+ yaml_document = YAML.safe_load(response.body, aliases: true)
+
+ assert_equal yaml_document, json_document
+ end
+
+ test "serves the document from the /api aliases too" do
+ get api_openapi_path
+ assert_response :success
+ assert_equal "application/json", response.media_type
+
+ get api_openapi_yaml_path
+ assert_response :success
+ assert_equal "application/yaml", response.media_type
+ end
+
+ test "document is publicly cacheable and readable cross-origin" do
+ get openapi_path
+
+ assert_response :success
+ assert_match(/public/, response.headers["Cache-Control"])
+ assert_equal "*", response.headers["Access-Control-Allow-Origin"]
+ end
+
+ test "document is served without authentication" do
+ get openapi_path
+
+ assert_response :success
+ assert_nil session[:user_id]
+ end
+
+ test "pages advertise the OpenAPI document with a service-desc link" do
+ get root_path
+
+ assert_response :success
+ assert_includes response.body, %()
+ end
+
+ test "robots.txt lets crawlers reach the OpenAPI document" do
+ robots = Rails.root.join("public", "robots.txt").read
+
+ assert_includes robots, "Allow: /openapi.json"
+ assert_includes robots, "Allow: /openapi.yaml"
+ assert_includes robots, "Allow: /api/openapi.json"
+ assert_includes robots, "Allow: /api/openapi.yaml"
+ end
+
+ test "document declares the production server" do
+ get openapi_path
+
+ servers = JSON.parse(response.body)["servers"]
+ assert_includes servers.map { |server| server.dig("variables", "defaultHost", "default") },
+ "hackatime.hackclub.com"
+ end
+end