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
15 changes: 10 additions & 5 deletions app/controllers/api/summary_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
module Api
class SummaryController < ApplicationController
include DateParsing

skip_before_action :verify_authenticity_token
before_action :set_user

Expand All @@ -10,6 +12,7 @@ def index
params[:from] || params[:start],
params[:to] || params[:end]
)
return if performed?
return render_bad_request("Invalid date range") unless date_range

service = WakatimeService.new(
Expand Down Expand Up @@ -54,11 +57,13 @@ def determine_date_range(interval, range, from_date, to_date)
Time.use_zone("UTC") do
now = Time.current

if from_date.present? && to_date.present?
from = parse_explicit_date(from_date, boundary: :start)
to = parse_explicit_date(to_date, boundary: :end)
return nil if from.nil? || to.nil?
return from..to
if from_date.present? || to_date.present?
return parse_date_range(
start_value: from_date,
end_value: to_date,
start_default: nil,
end_default: nil
) { |value, boundary| parse_explicit_date(value, boundary:) }
end

case (interval || range)
Expand Down
15 changes: 13 additions & 2 deletions app/controllers/api/v1/authenticated/hours_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,22 @@ module Api
module V1
module Authenticated
class HoursController < ApplicationController
include RenderHelpers
include DateParsing

require_oauth_scope :read

def index
start_date = params[:start_date]&.to_date || 7.days.ago.to_date
end_date = params[:end_date]&.to_date || Date.current
date_range = parse_date_range(
start_value: params[:start_date],
end_value: params[:end_date],
start_default: 7.days.ago.to_date,
end_default: Date.current
) { |value| value.to_s.to_date }
return unless date_range

start_date = date_range.begin
end_date = date_range.end

total_seconds = current_user.heartbeats
.where(time: start_date.beginning_of_day.to_i..end_date.end_of_day.to_i)
Expand Down
14 changes: 12 additions & 2 deletions app/controllers/api/v1/my/heartbeats_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
class Api::V1::My::HeartbeatsController < ApplicationController
include ActionView::Helpers::DateHelper
include DateParsing

before_action :ensure_authenticated!

def most_recent
Expand All @@ -24,8 +26,16 @@ def most_recent
end

def index
start_time = params[:start_time].present? ? Time.parse(params[:start_time]) : Time.current.beginning_of_day
end_time = params[:end_time].present? ? Time.parse(params[:end_time]) : Time.current.end_of_day
time_range = parse_date_range(
start_value: params[:start_time],
end_value: params[:end_time],
start_default: Time.current.beginning_of_day,
end_default: Time.current.end_of_day
) { |value| Time.parse(value) }
return unless time_range

start_time = time_range.begin
end_time = time_range.end

heartbeats = current_user.heartbeats
.where("time >= ? AND time <= ?", start_time.to_f, end_time.to_f)
Expand Down
73 changes: 45 additions & 28 deletions app/controllers/api/v1/stats_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class Api::V1::StatsController < ApplicationController
include DateParsing

USER_LOOKUP_ACTIONS = [ :user_stats, :user_spans, :user_projects, :user_project, :user_projects_details ].freeze

before_action :authenticate_admin_api_key!, only: [ :show ], unless: -> { Rails.env.development? }
Expand All @@ -7,12 +9,18 @@ class Api::V1::StatsController < ApplicationController

def show
# take either user_id with a start date & end date
start_date = parse_date_param(:start_date, default: 10.years.ago, boundary: :start)
return if performed?
end_date = parse_date_param(:end_date, default: Date.today.end_of_day, boundary: :end)
return if performed?
date_range = parse_date_range(
start_value: params[:start_date],
end_value: params[:end_date],
start_default: 10.years.ago,
end_default: Date.today.end_of_day
) do |value, boundary|
date = Date.iso8601(value)
boundary == :start ? date.beginning_of_day : date.end_of_day
end
return unless date_range

query = Heartbeat.where(time: start_date..end_date)
query = Heartbeat.where(time: date_range)

if params[:username].present?
user = User.lookup_by_identifier(params[:username])
Expand All @@ -31,10 +39,10 @@ def show

def user_stats
# Used by the github stats page feature
start_date = parse_datetime_param(:start_date, default: 10.years.ago)
return if performed?
end_date = parse_datetime_param(:end_date, default: Date.today.end_of_day)
return if performed?
date_range = default_datetime_range
return unless date_range
start_date = date_range.begin
end_date = date_range.end

# /api/v1/users/current/stats?filter_by_project=harbor,high-seas
filter_by_projects = params[:filter_by_project].presence&.split(",")
Expand Down Expand Up @@ -113,10 +121,10 @@ def user_stats
end

def user_spans
start_date = parse_datetime_param(:start_date, default: 10.years.ago)
return if performed?
end_date = parse_datetime_param(:end_date, default: Date.today.end_of_day)
return if performed?
date_range = default_datetime_range
return unless date_range
start_date = date_range.begin
end_date = date_range.end

heartbeats = @user.heartbeats.where(time: start_date.to_f..end_date.to_f)
heartbeats = heartbeats.where(project: params[:project]) if params[:project].present?
Expand Down Expand Up @@ -148,18 +156,28 @@ def banned_users_counts
end

def user_projects
return unless parse_date_range(
start_value: params[:since].presence || params[:start].presence || params[:start_date],
end_value: params[:until].presence || params[:until_date].presence || params[:end].presence || params[:end_date],
start_default: 30.days.ago.beginning_of_day,
end_default: Time.current
) { |value| value.to_datetime }

render json: { projects: project_stats_query(include_archived: true).project_names }
end

def user_project
return render_bad_request("whats the name?") unless params[:project_name].present?
return unless project_stats_date_range

project_data = project_stats_query.project_details(names: [ params[:project_name] ]).first
return render_not_found_json("found nuthin") unless project_data
render json: project_data
end

def user_projects_details
return unless project_stats_date_range

render json: { projects: project_stats_query.project_details(names: params[:projects]&.split(",")&.map(&:strip)) }
end

Expand Down Expand Up @@ -214,22 +232,21 @@ def unique_heartbeat_seconds(heartbeats)
total_seconds.to_i
end

def parse_date_param(param_name, default:, boundary:)
raw_value = params[param_name]
return default if raw_value.blank?
parsed_date = Date.iso8601(raw_value)
boundary == :start ? parsed_date.beginning_of_day : parsed_date.end_of_day
rescue ArgumentError, Date::Error, TypeError
render_error("Invalid #{param_name}")
def default_datetime_range
parse_date_range(
start_value: params[:start_date],
end_value: params[:end_date],
start_default: 10.years.ago,
end_default: Date.today.end_of_day
) { |value| Time.zone.parse(value.to_s) }
end

def parse_datetime_param(param_name, default:)
raw_value = params[param_name]
return default if raw_value.blank?
parsed_time = Time.zone.parse(raw_value.to_s)
raise ArgumentError if parsed_time.nil?
parsed_time
rescue ArgumentError, TypeError
render_error("Invalid #{param_name}")
def project_stats_date_range
parse_date_range(
start_value: params[:start].presence || params[:start_date],
end_value: params[:end].presence || params[:end_date],
start_default: 1.year.ago.to_datetime,
end_default: Time.current.to_datetime
) { |value| value.to_datetime }
end
end
15 changes: 14 additions & 1 deletion app/controllers/concerns/date_parsing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@

# Shared timestamp/date parsing helpers used by API endpoints that accept
# `start_date`/`end_date` (or arbitrary date params).
# Hosts must also include RenderHelpers (or define render_error).
# Hosts must also include RenderHelpers or provide equivalent render methods.
module DateParsing
extend ActiveSupport::Concern

# Parses and validates a pair of date/time values while allowing each endpoint
# to retain its existing parser and defaults.
def parse_date_range(start_value:, end_value:, start_default:, end_default:)
start_at = start_value.present? ? yield(start_value, :start) : start_default
end_at = end_value.present? ? yield(end_value, :end) : end_default

return render_bad_request("Invalid date range") if start_at.nil? || end_at.nil? || end_at < start_at

start_at..end_at
rescue Date::Error, ArgumentError, TypeError
render_bad_request("Invalid date range")
end

# Parses start_date/end_date params with defaults; returns a Range[start_ts..end_ts]
# or nil after rendering an error.
def parse_default_time_range
Expand Down
2 changes: 1 addition & 1 deletion spec/requests/api/summary_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
run_test!
end

response(400, 'invalid date range') do
response(400, 'invalid, incomplete or reversed date range') do
let(:date_test_user) { create(:user, slack_uid: "UDATE#{SecureRandom.hex(4)}", timezone: 'UTC', allow_public_stats_lookup: true) }
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { "dev-api-key-12345" }
Expand Down
14 changes: 12 additions & 2 deletions spec/requests/api/v1/authenticated_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@
security [ { OAuth2: [ 'read' ] } ]
produces 'application/json'

parameter name: :start_date, in: :query, schema: { type: :string, format: :date }, description: 'Start date (YYYY-MM-DD)'
parameter name: :end_date, in: :query, schema: { type: :string, format: :date }, description: 'End date (YYYY-MM-DD)'
parameter name: :start_date, in: :query, schema: { type: :string, format: :date }, description: 'Start date (YYYY-MM-DD), defaults to 7 days ago'
parameter name: :end_date, in: :query, schema: { type: :string, format: :date }, description: 'End date (YYYY-MM-DD), defaults to today'

response(200, 'successful') do
before { Doorkeeper::AccessToken.by_token('dev-api-key-12345').update!(scopes: 'read') }
Expand All @@ -70,6 +70,16 @@
run_test!
end

response(400, 'invalid or reversed date range') do
before { Doorkeeper::AccessToken.by_token('dev-api-key-12345').update!(scopes: 'read') }

let(:Authorization) { "Bearer dev-api-key-12345" }
let(:start_date) { 'not-a-date' }
let(:end_date) { Date.today.to_s }
schema '$ref' => '#/components/schemas/Error'
run_test!
end

response(403, 'insufficient scope') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:start_date) { 7.days.ago.to_date.to_s }
Expand Down
8 changes: 8 additions & 0 deletions spec/requests/api/v1/my_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ def login_browser_user
run_test!
end

response(400, 'invalid or reversed date range') do
let(:Authorization) { "Bearer dev-api-key-12345" }
let(:start_time) { 'not-a-date' }
let(:end_time) { Time.now.iso8601 }
schema '$ref' => '#/components/schemas/Error'
run_test!
end

response(401, 'unauthorized — Returned when the Authorization header is missing, the OAuth token lacks the read scope, or the token is invalid.') do
let(:Authorization) { 'Bearer invalid' }
let(:start_time) { 1.day.ago.iso8601 }
Expand Down
Loading