Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions app/controllers/openapi_controller.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions app/views/layouts/inertia.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
<meta name="author" content="Hack Club">
<meta name="robots" content="index, follow">
<link rel="canonical" href="<%= content_for(:canonical_url) || request.original_url %>">
<link rel="service-desc" type="application/json" href="<%= openapi_path %>">
<link rel="service-doc" type="text/html" href="<%= api_docs_path %>">
<meta name="theme-color" content="<%= current_theme_color %>">
<meta name="msapplication-TileColor" content="<%= current_theme_color %>">
<meta property="og:title" content="<%= @og_title || content_for(:og_title) || @page_title || content_for(:title) || 'Hackatime - Free Coding Time Tracker' %>">
Expand Down
9 changes: 9 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Get Hackatime running in your editor, customise your setup or build an integrati
Build an OAuth integration.
</Card>
<Card title="API reference" href="https://hackatime.hackclub.com/api-docs" arrow={false} icon={`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M3 6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V6Zm14.25 6a.75.75 0 0 1-.22.53l-2.25 2.25a.75.75 0 1 1-1.06-1.06L15.44 12l-1.72-1.72a.75.75 0 1 1 1.06-1.06l2.25 2.25c.141.14.22.331.22.53Zm-10.28-.53a.75.75 0 0 0 0 1.06l2.25 2.25a.75.75 0 1 0 1.06-1.06L8.56 12l1.72-1.72a.75.75 0 1 0-1.06-1.06l-2.25 2.25Z" clip-rule="evenodd"/></svg>`}>
Browse Hackatime endpoints.
Browse Hackatime endpoints, or fetch the OpenAPI spec from `/openapi.json`.
</Card>
<Card title="Troubleshooting" href="/docs/troubleshooting/hackatime-stuck-initialised" icon={`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M19.449 8.448 16.388 11a4.52 4.52 0 0 1 0 2.002l3.061 2.55a8.275 8.275 0 0 0 0-7.103ZM15.552 19.45 13 16.388a4.52 4.52 0 0 1-2.002 0l-2.55 3.061a8.275 8.275 0 0 0 7.103 0ZM4.55 15.552 7.612 13a4.52 4.52 0 0 1 0-2.002L4.551 8.45a8.275 8.275 0 0 0 0 7.103ZM8.448 4.55 11 7.612a4.52 4.52 0 0 1 2.002 0l2.55-3.061a8.275 8.275 0 0 0-7.103 0Zm8.657-.86a9.776 9.776 0 0 1 1.79 1.415 9.776 9.776 0 0 1 1.414 1.788 9.764 9.764 0 0 1 0 10.211 9.777 9.777 0 0 1-1.415 1.79 9.777 9.777 0 0 1-1.788 1.414 9.764 9.764 0 0 1-10.212 0 9.776 9.776 0 0 1-1.788-1.415 9.776 9.776 0 0 1-1.415-1.788 9.764 9.764 0 0 1 0-10.212 9.774 9.774 0 0 1 1.415-1.788A9.774 9.774 0 0 1 6.894 3.69a9.764 9.764 0 0 1 10.211 0ZM14.121 9.88a2.985 2.985 0 0 0-1.11-.704 3.015 3.015 0 0 0-2.022 0 2.985 2.985 0 0 0-1.11.704c-.326.325-.56.705-.704 1.11a3.015 3.015 0 0 0 0 2.022c.144.405.378.785.704 1.11.325.326.705.56 1.11.704.652.233 1.37.233 2.022 0a2.985 2.985 0 0 0 1.11-.704c.326-.325.56-.705.704-1.11a3.016 3.016 0 0 0 0-2.022 2.985 2.985 0 0 0-.704-1.11Z" clip-rule="evenodd"/></svg>`}>
Solve common setup problems.
Expand Down
7 changes: 7 additions & 0 deletions public/robots.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
4 changes: 2 additions & 2 deletions spec/requests/api/hackatime/v1/compatibility_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand Down
57 changes: 57 additions & 0 deletions spec/requests/api/openapi_spec.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions spec/requests/api/summary_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand Down
1 change: 1 addition & 0 deletions spec/requests/api/v1/badges_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand Down
1 change: 1 addition & 0 deletions spec/requests/api/v1/currently_hacking_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions spec/requests/api/v1/leaderboards_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions spec/requests/api/v1/stats_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: [] } ]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Query API key is unsupported

When a generated client selects ApiKeyAuth for one of these five stats operations, it sends ?api_key=..., but StatsController#set_user accepts API keys only from the Bearer header. The query key is ignored, causing private-user requests to return 403 or my lookups to return 404 despite following the published authentication contract; remove ApiKeyAuth from these declarations or update the shared authentication path to accept query keys.

Prompt To Fix With AI
This is a comment left during a code review.
Path: spec/requests/api/v1/stats_spec.rb
Line: 93

Comment:
**Query API key is unsupported**

When a generated client selects `ApiKeyAuth` for one of these five stats operations, it sends `?api_key=...`, but `StatsController#set_user` accepts API keys only from the Bearer header. The query key is ignored, causing private-user requests to return 403 or `my` lookups to return 404 despite following the published authentication contract; remove `ApiKeyAuth` from these declarations or update the shared authentication path to accept query keys.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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.'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
28 changes: 26 additions & 2 deletions spec/swagger_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
9 changes: 9 additions & 0 deletions swagger/admin/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading