Skip to content

Commit e03e4b8

Browse files
committed
enhance openapi docs
1 parent f1eb881 commit e03e4b8

16 files changed

Lines changed: 351 additions & 11 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Publishes the OpenAPI description of Hackatime's public API at the
2+
# conventional, machine-discoverable URLs (`/openapi.json`, `/openapi.yaml`
3+
# and their `/api` aliases). The Scalar reference at `/api-docs` is the human
4+
# entry point; these endpoints are what API clients, SDK generators and agents
5+
# fetch. Both formats render the same document that `rswag` generates into
6+
# `swagger/v1/swagger.yaml`, so there is a single source of truth.
7+
class OpenapiController < ApplicationController
8+
SPEC_PATH = Rails.root.join("swagger", "v1", "swagger.yaml").freeze
9+
CACHE_MAX_AGE = 1.hour
10+
11+
class << self
12+
def json_document
13+
@json_document ||= "#{JSON.pretty_generate(parsed_document)}\n"
14+
end
15+
16+
def yaml_document
17+
@yaml_document ||= SPEC_PATH.read
18+
end
19+
20+
private
21+
22+
def parsed_document
23+
YAML.safe_load(yaml_document, aliases: true)
24+
end
25+
end
26+
27+
def show_json
28+
render_spec self.class.json_document, "application/json"
29+
end
30+
31+
def show_yaml
32+
# RFC 9512 registers `application/yaml` as the media type for YAML.
33+
render_spec self.class.yaml_document, "application/yaml"
34+
end
35+
36+
private
37+
38+
def render_spec(body, content_type)
39+
# Overrides ApplicationController's `no-store` default: the document is
40+
# public, identical for every caller and only changes on deploy.
41+
response.headers["Cache-Control"] = "public, max-age=#{CACHE_MAX_AGE.to_i}"
42+
# Browser-based API consoles need to fetch the spec cross-origin.
43+
response.headers["Access-Control-Allow-Origin"] = "*"
44+
45+
render plain: body, content_type: content_type
46+
end
47+
end

app/views/layouts/inertia.html.erb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
<meta name="author" content="Hack Club">
1313
<meta name="robots" content="index, follow">
1414
<link rel="canonical" href="<%= content_for(:canonical_url) || request.original_url %>">
15+
<link rel="service-desc" type="application/json" href="<%= openapi_path %>">
16+
<link rel="service-doc" type="text/html" href="<%= api_docs_path %>">
1517
<meta name="theme-color" content="<%= current_theme_color %>">
1618
<meta name="msapplication-TileColor" content="<%= current_theme_color %>">
1719
<meta property="og:title" content="<%= @og_title || content_for(:og_title) || @page_title || content_for(:title) || 'Hackatime - Free Coding Time Tracker' %>">

config/routes.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ def matches?(request)
2222
get "api-docs", to: "api_docs#show", as: :api_docs
2323
get "api-docs/admin", to: "api_docs#admin", as: :admin_api_docs
2424
mount Rswag::Api::Engine => "/api-docs"
25+
26+
# Machine-readable description of the public API. /openapi.json is the
27+
# canonical location; the others are conventional aliases that API clients
28+
# and agents probe for.
29+
get "openapi.json", to: "openapi#show_json", as: :openapi, format: false
30+
get "openapi.yaml", to: "openapi#show_yaml", as: :openapi_yaml, format: false
31+
get "api/openapi.json", to: "openapi#show_json", as: :api_openapi, format: false
32+
get "api/openapi.yaml", to: "openapi#show_yaml", as: :api_openapi_yaml, format: false
33+
2534
defaults export: true do
2635
use_doorkeeper do
2736
controllers authorizations: "custom_doorkeeper/authorizations"

docs/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Get Hackatime running in your editor, customise your setup or build an integrati
2323
Build an OAuth integration.
2424
</Card>
2525
<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>`}>
26-
Browse Hackatime endpoints.
26+
Browse Hackatime endpoints, or fetch the OpenAPI spec from `/openapi.json`.
2727
</Card>
2828
<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>`}>
2929
Solve common setup problems.

public/robots.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ Allow: /
55
Allow: /docs
66
Allow: /docs/*
77
Allow: /leaderboards
8+
Allow: /api-docs
9+
10+
# Machine-readable API description
11+
Allow: /openapi.json
12+
Allow: /openapi.yaml
13+
Allow: /api/openapi.json
14+
Allow: /api/openapi.yaml
815

916
# Disallow private/internal pages
1017
Disallow: /my/

spec/requests/api/hackatime/v1/compatibility_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -285,8 +285,8 @@
285285
produces 'application/json'
286286

287287
parameter name: :id, in: :path, type: :string, description: 'User ID or "current" (recommended). The authenticated user is resolved from the API token.'
288-
parameter name: :start, in: :query, type: :string, format: :date, required: true, description: 'Inclusive start date in YYYY-MM-DD format.'
289-
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.'
288+
parameter name: :start, in: :query, schema: { type: :string, format: :date }, required: true, description: 'Inclusive start date in YYYY-MM-DD format.'
289+
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.'
290290
parameter name: :project, in: :query, type: :string, required: false, description: 'Only include activity for this project.'
291291
parameter name: :timezone, in: :query, type: :string, required: false, description: "Timezone used to segment days. Defaults to the user's timezone."
292292

spec/requests/api/openapi_spec.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
require 'swagger_helper'
2+
3+
RSpec.describe 'Api::Openapi', type: :request do
4+
path '/openapi.json' do
5+
get('Fetch the OpenAPI description of this API') do
6+
tags 'Discovery'
7+
description <<~DESC
8+
Returns this document. No authentication is required.
9+
10+
`/api/openapi.json` is an alias for the same resource, and the YAML
11+
representation is available at `/openapi.yaml` (aliased at
12+
`/api/openapi.yaml`).
13+
DESC
14+
security []
15+
produces 'application/json'
16+
17+
response(200, 'successful') do
18+
schema type: :object,
19+
description: 'An OpenAPI 3.0 document describing the Hackatime API.',
20+
properties: {
21+
openapi: { type: :string, example: '3.0.1' },
22+
info: { type: :object },
23+
paths: { type: :object },
24+
components: { type: :object },
25+
servers: { type: :array, items: { type: :object } }
26+
},
27+
required: %w[openapi info paths]
28+
29+
run_test! do |response|
30+
body = JSON.parse(response.body)
31+
expect(body['openapi']).to eq('3.0.1')
32+
expect(body.dig('info', 'title')).to eq('Hackatime API')
33+
expect(body['paths']).to be_a(Hash)
34+
end
35+
end
36+
end
37+
end
38+
39+
path '/openapi.yaml' do
40+
get('Fetch the OpenAPI description of this API as YAML') do
41+
tags 'Discovery'
42+
description 'The YAML representation of `/openapi.json`. No authentication is required.'
43+
security []
44+
produces 'application/yaml'
45+
46+
response(200, 'successful') do
47+
schema type: :string, description: 'An OpenAPI 3.0 document, serialised as YAML.'
48+
49+
run_test! do |response|
50+
body = YAML.safe_load(response.body, aliases: true)
51+
expect(body['openapi']).to eq('3.0.1')
52+
expect(body.dig('info', 'title')).to eq('Hackatime API')
53+
end
54+
end
55+
end
56+
end
57+
end

spec/requests/api/summary_spec.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
'This endpoint does NOT authenticate any API token: access is gated solely by the target ' \
99
'user (identified by user_id/user) having allow_public_stats_lookup enabled. No caller ' \
1010
'credentials are required or verified.'
11+
security []
1112
produces 'application/json'
1213

1314
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.'

spec/requests/api/v1/badges_spec.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def log_time(user, project, seconds: 600)
4242
Any additional query parameters not consumed below (e.g. `style`, `logo`,
4343
`logoColor`, `labelColor`) are passed straight through to shields.io.
4444
DESC
45+
security []
4546

4647
parameter name: :user_id, in: :path, type: :string, required: true,
4748
description: 'User identifier: Slack UID, username, or numeric internal ID.'

spec/requests/api/v1/currently_hacking_spec.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
on (if its repo mapping is not archived). The endpoint is public (no
1111
authentication required) and the result is cached for 5 minutes.
1212
DESC
13+
security []
1314
produces 'application/json'
1415

1516
response(200, 'successful') do

0 commit comments

Comments
 (0)