refactor: request thread safety context
This commit is contained in:
+66
-67
@@ -1,7 +1,4 @@
|
|||||||
require "uuid"
|
require "uuid"
|
||||||
require "user_agent_parser"
|
|
||||||
|
|
||||||
require "../lib/controller.cr"
|
|
||||||
|
|
||||||
module App::Controllers
|
module App::Controllers
|
||||||
class LinkController < App::Lib::BaseController
|
class LinkController < App::Lib::BaseController
|
||||||
@@ -9,17 +6,20 @@ module App::Controllers
|
|||||||
include App::Lib
|
include App::Lib
|
||||||
include App::Services
|
include App::Services
|
||||||
|
|
||||||
ClickTracker.init
|
def initialize(@env : HTTP::Server::Context)
|
||||||
|
ClickTracker.init
|
||||||
|
super(@env)
|
||||||
|
end
|
||||||
|
|
||||||
def create(env)
|
def create
|
||||||
user = env.get("user").as(User)
|
user = current_user
|
||||||
body = parse_body(env, ["url"])
|
body = parse_body(["url"])
|
||||||
url = body["url"].to_s
|
url = body["url"].to_s
|
||||||
|
|
||||||
query = Database::Query.where(url: url, user_id: user.id.as(String)).limit(1)
|
query = Database::Query.where(url: url, user_id: user.id.as(String)).limit(1)
|
||||||
existing_link = Database.all(Link, query, preload: [:clicks]).first?
|
existing_link = Database.all(Link, query, preload: [:clicks]).first?
|
||||||
if existing_link
|
if existing_link
|
||||||
return link_response(existing_link)
|
return render_json({"data" => App::Serializers::Link.new(existing_link)})
|
||||||
end
|
end
|
||||||
|
|
||||||
link = Link.new
|
link = Link.new
|
||||||
@@ -30,42 +30,41 @@ module App::Controllers
|
|||||||
|
|
||||||
changeset = Database.insert(link)
|
changeset = Database.insert(link)
|
||||||
if !changeset.valid?
|
if !changeset.valid?
|
||||||
raise App::UnprocessableEntityException.new(env, map_changeset_errors(changeset.errors))
|
raise App::UnprocessableEntityException.new(@env, map_changeset_errors(changeset.errors))
|
||||||
end
|
end
|
||||||
|
|
||||||
link.clicks = [] of App::Models::Click
|
link.clicks = [] of App::Models::Click
|
||||||
link_response(link)
|
render_json({"data" => App::Serializers::Link.new(link)}, 201)
|
||||||
end
|
end
|
||||||
|
|
||||||
def redirect(env)
|
def redirect
|
||||||
slug = env.params.url["slug"]
|
slug = @env.params.url["slug"]
|
||||||
|
|
||||||
link = nil
|
link_data = nil
|
||||||
Database.raw_query("SELECT id, slug, url FROM links WHERE slug = (?) LIMIT 1", slug) do |result|
|
Database.raw_query("SELECT id, url FROM links WHERE slug = (?) LIMIT 1", slug) do |result|
|
||||||
if result.move_next
|
if result.move_next
|
||||||
link = {
|
link_data = {result.read(String), result.read(String)}
|
||||||
id: result.read(String),
|
|
||||||
url: result.read(String),
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
raise App::NotFoundException.new(env) if !link
|
raise App::NotFoundException.new(@env) unless link_data
|
||||||
|
|
||||||
remote_address = env.request.headers["Cf-Connecting-Ip"]?.try(&.presence) || env.request.remote_address.try &.to_s
|
remote_address = @env.request.headers["Cf-Connecting-Ip"]?.try(&.presence) || @env.request.remote_address.try &.to_s
|
||||||
user_agent_str = env.request.headers["User-Agent"]? || "Unknown"
|
user_agent_str = @env.request.headers["User-Agent"]? || "Unknown"
|
||||||
client_ip = IpLookup.extract_ip(remote_address) || "Unknown"
|
client_ip = IpLookup.extract_ip(remote_address) || "Unknown"
|
||||||
|
|
||||||
env.response.status_code = 301
|
@env.response.status_code = 301
|
||||||
env.response.headers["Location"] = link.not_nil![:url]
|
@env.response.headers["Connection"] = "close"
|
||||||
env.response.headers["X-Forwarded-For"] = client_ip
|
|
||||||
env.response.headers["User-Agent"] = user_agent_str
|
|
||||||
|
|
||||||
spawn track_click(env, link.not_nil![:id], client_ip, user_agent_str)
|
@env.response.headers["Location"] = link_data[1]
|
||||||
|
@env.response.headers["X-Forwarded-For"] = client_ip
|
||||||
|
@env.response.headers["User-Agent"] = user_agent_str
|
||||||
|
|
||||||
|
spawn track_click(link_data[0], client_ip, user_agent_str)
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_all(env)
|
def list_all
|
||||||
user = env.get("user").as(User)
|
user = current_user
|
||||||
limit, cursor = pagination_params(env)
|
limit, cursor = pagination_params
|
||||||
|
|
||||||
query = Database::Query.where(user_id: user.id.as(String))
|
query = Database::Query.where(user_id: user.id.as(String))
|
||||||
query = query.where("id < ?", cursor) if cursor
|
query = query.where("id < ?", cursor) if cursor
|
||||||
@@ -75,32 +74,32 @@ module App::Controllers
|
|||||||
return paginated_response(links, limit) { |link| App::Serializers::Link.new(link) }
|
return paginated_response(links, limit) { |link| App::Serializers::Link.new(link) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def get(env)
|
def get
|
||||||
user = env.get("user").as(User)
|
user = current_user
|
||||||
link_id = env.params.url["id"]
|
link_id = @env.params.url["id"]
|
||||||
|
|
||||||
query = Database::Query.where(id: link_id.as(String), user_id: user.id.as(String)).limit(1)
|
query = Database::Query.where(id: link_id.as(String), user_id: user.id.as(String)).limit(1)
|
||||||
link = Database.all(Link, query).first?
|
link = Database.all(Link, query).first?
|
||||||
raise App::NotFoundException.new(env) if link.nil?
|
raise App::NotFoundException.new(@env) if link.nil?
|
||||||
|
|
||||||
clicks_query = Database::Query.where(link_id: link_id.as(String))
|
clicks_query = Database::Query.where(link_id: link_id.as(String))
|
||||||
.order_by("id DESC")
|
.order_by("id DESC")
|
||||||
.limit(100)
|
.limit(100)
|
||||||
link.clicks = Database.all(Click, clicks_query)
|
link.clicks = Database.all(Click, clicks_query)
|
||||||
|
|
||||||
link_response(link)
|
render_json({"data" => App::Serializers::Link.new(link)})
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_clicks(env)
|
def list_clicks
|
||||||
user = env.get("user").as(User)
|
user = current_user
|
||||||
link_id = env.params.url["id"]
|
link_id = @env.params.url["id"]
|
||||||
|
|
||||||
# Verify link exists and belongs to user
|
# Verify link exists and belongs to user
|
||||||
link_query = Database::Query.where(id: link_id.as(String), user_id: user.id.as(String)).limit(1)
|
link_query = Database::Query.where(id: link_id.as(String), user_id: user.id.as(String)).limit(1)
|
||||||
link = Database.all(Link, link_query).first?
|
link = Database.all(Link, link_query).first?
|
||||||
raise App::NotFoundException.new(env) if link.nil?
|
raise App::NotFoundException.new(@env) if link.nil?
|
||||||
|
|
||||||
limit, cursor = pagination_params(env)
|
limit, cursor = pagination_params
|
||||||
|
|
||||||
query = Database::Query.where(link_id: link_id.as(String))
|
query = Database::Query.where(link_id: link_id.as(String))
|
||||||
query = query.where("id < ?", cursor) if cursor
|
query = query.where("id < ?", cursor) if cursor
|
||||||
@@ -110,22 +109,22 @@ module App::Controllers
|
|||||||
return paginated_response(clicks, limit) { |click| App::Serializers::Click.new(click) }
|
return paginated_response(clicks, limit) { |click| App::Serializers::Click.new(click) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def update(env)
|
def update
|
||||||
user = env.get("user").as(User)
|
user = current_user
|
||||||
id = env.params.url["id"]
|
id = @env.params.url["id"]
|
||||||
body = parse_body(env, ["url"])
|
body = parse_body(["url"])
|
||||||
new_url = body["url"].to_s
|
new_url = body["url"].to_s
|
||||||
|
|
||||||
query = Database::Query.where(id: id).limit(1)
|
query = Database::Query.where(id: id).limit(1)
|
||||||
link = Database.all(Link, query, preload: [:clicks]).first?
|
link = Database.all(Link, query, preload: [:clicks]).first?
|
||||||
|
|
||||||
raise App::NotFoundException.new(env) if link.nil?
|
raise App::NotFoundException.new(@env) if link.nil?
|
||||||
raise App::ForbiddenException.new(env) if link.user_id != user.id
|
raise App::ForbiddenException.new(@env) if link.user_id != user.id
|
||||||
|
|
||||||
# Check for existing URL
|
# Check for existing URL
|
||||||
existing_query = Database::Query.where(url: new_url, user_id: user.id.to_s).limit(1)
|
existing_query = Database::Query.where(url: new_url, user_id: user.id.to_s).limit(1)
|
||||||
if Database.all(Link, existing_query).first?
|
if Database.all(Link, existing_query).first?
|
||||||
raise App::UnprocessableEntityException.new(env, { "url" => ["URL already exists"] })
|
raise App::UnprocessableEntityException.new(@env, { "url" => ["URL already exists"] })
|
||||||
end
|
end
|
||||||
|
|
||||||
link.url = new_url
|
link.url = new_url
|
||||||
@@ -133,31 +132,35 @@ module App::Controllers
|
|||||||
|
|
||||||
changeset = Database.update(link)
|
changeset = Database.update(link)
|
||||||
if !changeset.valid?
|
if !changeset.valid?
|
||||||
raise App::UnprocessableEntityException.new(env, map_changeset_errors(changeset.errors))
|
raise App::UnprocessableEntityException.new(@env, map_changeset_errors(changeset.errors))
|
||||||
end
|
end
|
||||||
|
|
||||||
link_response(link)
|
render_json({"data" => App::Serializers::Link.new(link)})
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete(env)
|
def delete
|
||||||
user = env.get("user").as(User)
|
user = current_user
|
||||||
id = env.params.url["id"]
|
id = @env.params.url["id"]
|
||||||
|
|
||||||
link = Database.get(Link, id)
|
link = Database.get(Link, id)
|
||||||
raise App::NotFoundException.new(env) if !link
|
raise App::NotFoundException.new(@env) if !link
|
||||||
raise App::ForbiddenException.new(env) if link.user_id != user.id
|
raise App::ForbiddenException.new(@env) if link.user_id != user.id
|
||||||
|
|
||||||
result = Database.raw_exec("DELETE FROM links WHERE id = (?)", link.id)
|
result = Database.raw_exec("DELETE FROM links WHERE id = (?)", link.id)
|
||||||
if result.rows_affected == 0
|
if result.rows_affected == 0
|
||||||
raise App::UnprocessableEntityException.new(env, { "id" => ["Row delete failed"] })
|
raise App::UnprocessableEntityException.new(@env, { "id" => ["Row delete failed"] })
|
||||||
end
|
end
|
||||||
|
|
||||||
env.response.status_code = 204
|
@env.response.status_code = 204
|
||||||
end
|
end
|
||||||
|
|
||||||
private def track_click(env, link_id, client_ip, user_agent_str)
|
private def current_user : User
|
||||||
source = env.params.query["utm_source"]? || "Direct"
|
@env.get("user").as(User)
|
||||||
referer = env.request.headers["Referer"]?.try { |r| begin URI.parse(r).host rescue r end } || source
|
end
|
||||||
|
|
||||||
|
private def track_click(link_id, client_ip, user_agent_str)
|
||||||
|
source = @env.params.query["utm_source"]? || "Direct"
|
||||||
|
referer = @env.request.headers["Referer"]?.try { |r| begin URI.parse(r).host rescue r end } || source
|
||||||
|
|
||||||
ClickTracker.track(
|
ClickTracker.track(
|
||||||
link_id: link_id,
|
link_id: link_id,
|
||||||
@@ -168,28 +171,24 @@ module App::Controllers
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
private def pagination_params(env)
|
private def pagination_params
|
||||||
limit = (env.params.query["limit"]? || "100").to_i
|
limit = (@env.params.query["limit"]? || "100").to_i
|
||||||
cursor = env.params.query["cursor"]?
|
cursor = @env.params.query["cursor"]?
|
||||||
{limit, cursor}
|
{limit, cursor}
|
||||||
end
|
end
|
||||||
|
|
||||||
private def link_response(link)
|
|
||||||
{"data" => App::Serializers::Link.new(link)}.to_json
|
|
||||||
end
|
|
||||||
|
|
||||||
private def paginated_response(items, limit)
|
private def paginated_response(items, limit)
|
||||||
has_more = items.size > limit
|
has_more = items.size > limit
|
||||||
items = items[0...limit] if has_more
|
items = items[0...limit] if has_more
|
||||||
next_cursor = has_more ? items.last.id : nil
|
next_cursor = has_more ? items.last.id : nil
|
||||||
|
|
||||||
{
|
render_json({
|
||||||
"data" => items.map { |item| yield item },
|
"data" => items.map { |item| yield item },
|
||||||
"pagination" => {
|
"pagination" => {
|
||||||
"has_more" => has_more,
|
"has_more" => has_more,
|
||||||
"next" => next_cursor
|
"next" => next_cursor
|
||||||
}
|
}
|
||||||
}.to_json
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
require "../lib/controller.cr"
|
require "../lib/controller.cr"
|
||||||
|
|
||||||
module App::Controllers::Ping
|
module App::Controllers
|
||||||
class Get < App::Lib::BaseController
|
class PingController < App::Lib::BaseController
|
||||||
def call(env)
|
def initialize(@env : HTTP::Server::Context)
|
||||||
response = {"data" => "pong"}
|
super(@env)
|
||||||
response.to_json
|
end
|
||||||
|
|
||||||
|
def ping
|
||||||
|
render_json({data: "pong"})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+29
-13
@@ -1,29 +1,45 @@
|
|||||||
module App::Lib
|
module App::Lib
|
||||||
abstract class BaseController
|
abstract class BaseController
|
||||||
def map_changeset_errors(errors)
|
protected getter env : HTTP::Server::Context
|
||||||
|
|
||||||
|
def initialize(@env : HTTP::Server::Context); end
|
||||||
|
|
||||||
|
# Convert changeset errors to API-friendly format
|
||||||
|
protected def map_changeset_errors(errors)
|
||||||
errors.reduce({} of String => Array(String)) do |memo, error|
|
errors.reduce({} of String => Array(String)) do |memo, error|
|
||||||
memo[error[:field]] = memo[error[:field]]? || [] of String
|
field = error[:field].to_s
|
||||||
memo[error[:field]] << error[:message]
|
message = error[:message].to_s
|
||||||
|
|
||||||
|
memo[field] ||= [] of String
|
||||||
|
memo[field] << message
|
||||||
memo
|
memo
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def parse_body(env, fields)
|
protected def parse_body(required_fields : Array(String) = [] of String)
|
||||||
json_params = env.params.json.to_h
|
json_params = @env.params.json.try(&.to_h) || {} of String => JSON::Any
|
||||||
missing_fields = [] of String
|
json_params = json_params.transform_values(&.to_s) # Convert JSON::Any to String
|
||||||
|
|
||||||
fields.each do |field|
|
missing_fields = required_fields.reject { |field| json_params.has_key?(field) }
|
||||||
unless json_params.has_key?(field)
|
|
||||||
missing_fields << field
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
unless missing_fields.empty?
|
unless missing_fields.empty?
|
||||||
error_message = missing_fields.map { |field| "#{field}: Required field" }.join(", ")
|
error_message = missing_fields.join(", ") + " required"
|
||||||
raise App::BadRequestException.new(env, error_message)
|
raise App::BadRequestException.new(@env, error_message)
|
||||||
end
|
end
|
||||||
|
|
||||||
json_params
|
json_params
|
||||||
end
|
end
|
||||||
|
|
||||||
|
protected def render_json(data, status_code : Int32 = 200)
|
||||||
|
@env.response.status_code = status_code
|
||||||
|
@env.response.content_type = "application/json"
|
||||||
|
data.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
protected def param(key : String) : String
|
||||||
|
@env.params.url[key]
|
||||||
|
rescue KeyError
|
||||||
|
raise App::BadRequestException.new(@env, "Missing required parameter: #{key}")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+23
-15
@@ -1,45 +1,53 @@
|
|||||||
require "./controllers/**"
|
require "./controllers/**"
|
||||||
|
|
||||||
module App
|
module App
|
||||||
|
# CORS handling middleware
|
||||||
before_all do |env|
|
before_all do |env|
|
||||||
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
if env.request.path.starts_with?("/api/")
|
||||||
env.response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
env.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Accept, Origin, X-Api-Key"
|
env.response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
||||||
|
env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Accept, Origin, X-Api-Key"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
after_all do |env|
|
# Error handling middleware
|
||||||
env.response.content_type = "application/json"
|
error 404 do |env|
|
||||||
|
{error: "Not Found"}.to_json
|
||||||
end
|
end
|
||||||
|
error 500 do |env|
|
||||||
get "/api/ping" do |env|
|
{error: "Internal Server Error"}.to_json
|
||||||
Controllers::Ping::Get.new.call(env)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/:slug" do |env|
|
get "/:slug" do |env|
|
||||||
Controllers::Link::Index.new.call(env)
|
Controllers::LinkController.new(env).redirect
|
||||||
|
end
|
||||||
|
|
||||||
|
# namespace /api
|
||||||
|
get "/api/ping" do |env|
|
||||||
|
Controllers::PingController.new(env).ping
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/links" do |env|
|
get "/api/links" do |env|
|
||||||
Controllers::Link::All.new.call(env)
|
Controllers::LinkController.new(env).list_all
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/links/:id" do |env|
|
get "/api/links/:id" do |env|
|
||||||
Controllers::Link::Get.new.call(env)
|
Controllers::LinkController.new(env).get
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/links/:id/clicks" do |env|
|
get "/api/links/:id/clicks" do |env|
|
||||||
Controllers::Link::Clicks.new.call(env)
|
Controllers::LinkController.new(env).list_clicks
|
||||||
end
|
end
|
||||||
|
|
||||||
post "/api/links" do |env|
|
post "/api/links" do |env|
|
||||||
Controllers::Link::Create.new.call(env)
|
Controllers::LinkController.new(env).create
|
||||||
end
|
end
|
||||||
|
|
||||||
put "/api/links/:id" do |env|
|
put "/api/links/:id" do |env|
|
||||||
Controllers::Link::Update.new.call(env)
|
Controllers::LinkController.new(env).update
|
||||||
end
|
end
|
||||||
|
|
||||||
delete "/api/links/:id" do |env|
|
delete "/api/links/:id" do |env|
|
||||||
Controllers::Link::Delete.new.call(env)
|
Controllers::LinkController.new(env).delete
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user