dynamic_links 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +142 -6
  5. data/app/controllers/dynamic_links/application_controller.rb +15 -1
  6. data/app/controllers/dynamic_links/redirects_controller.rb +29 -0
  7. data/app/controllers/dynamic_links/v1/short_links_controller.rb +54 -12
  8. data/app/jobs/dynamic_links/generate_short_links_job.rb +1 -1
  9. data/app/jobs/dynamic_links/shorten_url_job.rb +31 -0
  10. data/app/models/dynamic_links/client.rb +5 -0
  11. data/app/models/dynamic_links/shortened_url.rb +20 -2
  12. data/config/routes.rb +2 -0
  13. data/db/migrate/20231228165744_create_dynamic_links_clients.rb +8 -1
  14. data/db/migrate/20231228175142_create_dynamic_links_shortened_urls.rb +3 -4
  15. data/db/migrate/20240128030329_fix_citus_index.rb +34 -0
  16. data/db/migrate/20240128030419_add_unique_index_to_shortened_urls.rb +5 -0
  17. data/lib/dynamic_links/async/locker.rb +60 -0
  18. data/lib/dynamic_links/configuration.rb +92 -3
  19. data/lib/dynamic_links/error_classes.rb +6 -0
  20. data/lib/dynamic_links/logger.rb +32 -0
  21. data/lib/dynamic_links/redis_config.rb +22 -0
  22. data/lib/dynamic_links/shortener.rb +52 -0
  23. data/lib/dynamic_links/shortening_strategies/base_strategy.rb +6 -0
  24. data/lib/dynamic_links/shortening_strategies/nano_id_strategy.rb +7 -9
  25. data/lib/dynamic_links/shortening_strategies/redis_counter_strategy.rb +25 -14
  26. data/lib/dynamic_links/strategy_factory.rb +28 -1
  27. data/lib/dynamic_links/validator.rb +14 -0
  28. data/lib/dynamic_links/version.rb +1 -1
  29. data/lib/dynamic_links.rb +42 -17
  30. metadata +73 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8be4269ba92cfbec08c29ec6a6a871383b8470736057204fa24234ebfc70a98
4
- data.tar.gz: 90a2df3615def525638fc53eeb23eef0a367d0137993b6303630d7d868ac3443
3
+ metadata.gz: d7f8c21c9c9432cdd2ed12ddc9e86a79b8f3024a240a406bba89157478520c9d
4
+ data.tar.gz: 30e2a4084f34977c4e267aa940454e53978ad193d99214312f90b6ee948fda2d
5
5
  SHA512:
6
- metadata.gz: 3ca6e9c92116542c9df6895e861843c03e951cb7e87bf567bc189b4811f8fb6cf95e097123b4569843ea18f8780289e1bec70b2de2a8bd92532f929277e0578e
7
- data.tar.gz: 50e65b566b1b36f9b169f0a7c35610720027f030c31d4fd7fe861ee077ea1b5c043391a51caf4f34c612f13456fdcf16cbb95d39a019c69f65bb2676bc2d7fd7
6
+ metadata.gz: 1deecaad47165bae46df566e68661bc77444aa36d143084d7e1a6aa76d05958e9183b1f88a9a083813404778e9f305d8588887a1558b8d8f98c1ec66c1c07e10
7
+ data.tar.gz: bc0572b2ce3274ada41824867284e47d82263db2b2e2c323c8306f1c5487cd30040fe3b677b950880d17b026a997daa3907bbd060d3fdda3db83076f1566fbe3
data/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.2.0] - 2025-06-17
8
+
9
+ - [#88](https://github.com/saiqulhaq/dynamic_links/pull/88)
10
+
11
+ - New Feature: Added API method to expand shortened URLs via the `expand` endpoint
12
+ - New Feature: Added fallback mode to redirect to Firebase host when short URL not found
13
+ - Enhancement: Improved multi-tenant support in controllers
14
+
15
+ - [#19](https://github.com/saiqulhaq/dynamic_links/pull/19)
16
+
17
+ - New Feature: Added asynchronous URL shortening functionality, improving performance for large-scale operations.
18
+ - Refactor: Updated DynamicLinks::Configuration class to accommodate new features and improve flexibility.
19
+ - Test: Expanded test coverage to include new features and functionalities.
20
+ - Documentation: Updated README with instructions on performance optimization and running unit tests.
21
+ - Chore: Added benchmarking scripts to measure the performance of synchronous vs asynchronous URL shortening and different versions of `create_or_find` method.
22
+
23
+ - Custom domain per client
24
+ - Ruby API for URL shortening.
25
+ - Add CRC32, nanoid, Redis counter, and KGS strategies to shorten an URL.
26
+ - URL validation feature.
27
+ - Configuration option to enable/disable REST API.
28
+ - Redirection feature. [#14](https://github.com/saiqulhaq/dynamic_links/pull/14)
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023-2025 Saiqul Haq
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md CHANGED
@@ -1,10 +1,14 @@
1
1
  # DynamicLinks
2
2
 
3
+ [![Unit Tests](https://github.com/saiqulhaq/dynamic_links/actions/workflows/unit_test.yml/badge.svg)](https://github.com/saiqulhaq/dynamic_links/actions/workflows/unit_test.yml)
4
+
3
5
  DynamicLinks is a flexible URL shortening Ruby gem, designed to provide various strategies for URL shortening, similar to Firebase Dynamic Links.
4
6
 
7
+ By default, encoding strategies such as MD5 will generate the same short URL for the same input URL. This behavior ensures consistency and prevents the creation of multiple records for identical URLs. For scenarios requiring unique short URLs for each request, strategies like RedisCounterStrategy can be used, which generate a new short URL every time, regardless of the input URL.
8
+
5
9
  ## Usage
6
10
 
7
- To use DynamicLinks, you need to configure the shortening strategy in an initializer or before you start shortening URLs.
11
+ To use DynamicLinks, you need to configure the shortening strategy and other settings in an initializer or before you start shortening URLs.
8
12
 
9
13
  ### Configuration
10
14
 
@@ -12,10 +16,70 @@ In your Rails initializer or similar setup code, configure DynamicLinks like thi
12
16
 
13
17
  ```ruby
14
18
  DynamicLinks.configure do |config|
15
- config.shortening_strategy = :MD5
19
+ config.shortening_strategy = :md5 # Default strategy
20
+ config.redis_config = { host: 'localhost', port: 6379 } # Redis configuration
21
+ config.redis_pool_size = 10 # Redis connection pool size
22
+ config.redis_pool_timeout = 3 # Redis connection pool timeout in seconds
23
+ config.enable_rest_api = true # Enable or disable REST API feature
24
+
25
+ # New configuration added in PR #88
26
+ config.enable_fallback_mode = false # When true, falls back to Firebase URL if a short link is not found
27
+ config.firebase_host = "https://example.app.goo.gl" # Firebase host URL for fallbacks
16
28
  end
17
29
  ```
18
30
 
31
+ ## Development Environment
32
+
33
+ This project supports two development environment options: GitHub Codespaces and local Docker Compose.
34
+
35
+ ### Option 1: GitHub Codespaces
36
+
37
+ This project is configured to work with GitHub development containers, providing a consistent development environment.
38
+
39
+ #### Opening in GitHub Codespaces
40
+
41
+ 1. Navigate to the GitHub repository
42
+ 2. Click the "Code" button
43
+ 3. Select the "Codespaces" tab
44
+ 4. Click "Create codespace on main"
45
+
46
+ #### Development in the Codespace
47
+
48
+ Once the development container is created and set up:
49
+
50
+ 1. The container includes Ruby 3.2, PostgreSQL, Redis, and other dependencies
51
+ 2. Run the test suite: `cd test/dummy && bin/rails test`
52
+ 3. Start the Rails server: `cd test/dummy && bin/rails server`
53
+
54
+ ### Option 2: Local Development with Docker Compose
55
+
56
+ For local development, we use Docker Compose with VS Code's Remote - Containers extension.
57
+
58
+ #### Prerequisites
59
+
60
+ 1. Install [Docker](https://docs.docker.com/get-docker/)
61
+ 2. Install [VS Code](https://code.visualstudio.com/)
62
+ 3. Install the [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
63
+
64
+ #### Opening in VS Code with Containers
65
+
66
+ 1. Clone the repository to your local machine
67
+ 2. Open the project folder in VS Code
68
+ 3. VS Code will detect the devcontainer configuration and prompt you to reopen in a container
69
+ 4. Click "Reopen in Container"
70
+
71
+ #### Working with the Docker Compose Setup
72
+
73
+ - The setup includes three services: app (Ruby), postgres (PostgreSQL), and redis (Redis)
74
+ - Database and Redis connections are automatically configured
75
+ - Use VS Code tasks (F1 -> "Tasks: Run Task") for common operations like:
76
+ - Starting the Rails server
77
+ - Running tests
78
+ - Running the Rails console
79
+ - Managing Docker Compose services
80
+
81
+ For more details on the Docker Compose setup, refer to the [Docker Compose documentation](DOCKER_COMPOSE.md). 4. Access the application at the forwarded port (usually port 3000)
82
+
19
83
  ### Shortening a URL
20
84
 
21
85
  To shorten a URL, simply call:
@@ -24,16 +88,64 @@ To shorten a URL, simply call:
24
88
  shortened_url = DynamicLinks.shorten_url("https://example.com")
25
89
  ```
26
90
 
91
+ ### Expanding a Short URL
92
+
93
+ To expand (resolve) a short URL back to its original URL, use the `resolve_short_url` method:
94
+
95
+ ```ruby
96
+ original_url = DynamicLinks.resolve_short_url("abc123")
97
+ # Returns the original URL or nil if not found
98
+ ```
99
+
100
+ #### Using the REST API to Expand URLs
101
+
102
+ The gem also provides a REST API endpoint to expand short URLs:
103
+
104
+ ```
105
+ GET /v1/shortLinks/:short_url?api_key=YOUR_API_KEY
106
+ ```
107
+
108
+ Example response:
109
+
110
+ ```json
111
+ {
112
+ "full_url": "https://example.com/original-path"
113
+ }
114
+ ```
115
+
116
+ If the URL is not found, you'll receive a 404 status code with an error message:
117
+
118
+ ```json
119
+ {
120
+ "error": "Short link not found"
121
+ }
122
+ ```
123
+
124
+ ## Fallback Mode
125
+
126
+ The fallback mode feature (added in PR #88) allows your application to redirect users to a Firebase Dynamic Links URL when a short URL is not found in your system. This can be useful in migration scenarios or when running both systems in parallel.
127
+
128
+ To enable this feature:
129
+
130
+ ```ruby
131
+ DynamicLinks.configure do |config|
132
+ config.enable_fallback_mode = true
133
+ config.firebase_host = "https://your-app.page.link" # Your Firebase Dynamic Links URL
134
+ end
135
+ ```
136
+
137
+ When a user visits a short link that doesn't exist in your database, they will be redirected to the equivalent Firebase URL instead of receiving a 404 error.
138
+
27
139
  ## Available Shortening Strategies
28
140
 
29
- DynamicLinks supports various shortening strategies. The default strategy is `MD5`, but you can choose among several others, including `NanoIdStrategy`, `RedisCounterStrategy`, `Sha256Strategy`, and more.
141
+ DynamicLinks supports various shortening strategies. The default strategy is now `NanoIdStrategy` (changed from `MD5` in PR #88), but you can choose among several others, including `MD5Strategy`, `RedisCounterStrategy`, `Sha256Strategy`, and more.
30
142
 
31
143
  Depending on the strategy you choose, you may need to install additional dependencies.
32
144
 
33
145
  ### Optional Dependencies
34
146
 
35
147
  - For `NanoIdStrategy`, add `gem 'nanoid', '~> 2.0'` to your Gemfile.
36
- - For `RedisCounterStrategy`, add `gem 'redis', '>= 4'` to your Gemfile.
148
+ - For `RedisCounterStrategy`, ensure Redis is available and configured. Redis strategy requires `connection_pool` gem too.
37
149
 
38
150
  Ensure you bundle these dependencies along with the DynamicLinks gem if you plan to use these strategies.
39
151
 
@@ -57,9 +169,33 @@ Or install it yourself as:
57
169
  $ gem install dynamic_links
58
170
  ```
59
171
 
60
- ## Contributing
172
+ ## Performance
173
+
174
+ Shorten an URL using Ruby:
175
+ Shorten an URL using API:
176
+
177
+ ## How to run the unit test
178
+
179
+ ### When using a Plain PostgreSQL DB
180
+
181
+ ```bash
182
+ rails db:setup
183
+ rails db:test:prepare
184
+ rails test
185
+ ```
186
+
187
+ ### When using PostgreSQL DB with Citus
188
+
189
+ ```bash
190
+ export CITUS_ENABLED=true
191
+ rails db:setup
192
+ rails db:test:prepare
193
+ rails test
194
+ ```
61
195
 
62
- We welcome contributions to DynamicLinks! Please read the contributing guidelines to get started.
196
+ Note:
197
+ Make sure the Citus extension already enabled on the installed PostgreSQL
198
+ We don't manage it on Rails.
63
199
 
64
200
  ## License
65
201
 
@@ -1,4 +1,18 @@
1
1
  module DynamicLinks
2
- class ApplicationController < ActionController::Base
2
+ class ApplicationController < ActionController::API
3
+ def multi_tenant(client, db_infra_strategy = DynamicLinks.configuration.db_infra_strategy)
4
+ if db_infra_strategy == :sharding
5
+ if defined?(::MultiTenant)
6
+ ::MultiTenant.with(client) do
7
+ yield
8
+ end
9
+ else
10
+ Rails.logger.warn 'MultiTenant gem is not installed. Please install it to use sharding strategy'
11
+ yield
12
+ end
13
+ else
14
+ yield
15
+ end
16
+ end
3
17
  end
4
18
  end
@@ -0,0 +1,29 @@
1
+ module DynamicLinks
2
+ class RedirectsController < ApplicationController
3
+ def show
4
+ client = DynamicLinks::Client.find_by({ hostname: request.host })
5
+ unless client
6
+ render plain: 'URL not found', status: :not_found
7
+ return
8
+ end
9
+
10
+ multi_tenant(client) do
11
+ short_url = params[:short_url]
12
+ link = ShortenedUrl.find_by(short_url: short_url)
13
+
14
+ if link.nil?
15
+ if DynamicLinks.configuration.enable_fallback_mode && DynamicLinks.configuration.firebase_host.present?
16
+ redirect_to "#{DynamicLinks.configuration.firebase_host}/#{short_url}", status: :found, allow_other_host: true
17
+ else
18
+ render plain: 'Not found', status: :not_found
19
+ end
20
+ return
21
+ end
22
+
23
+ raise ActiveRecord::RecordNotFound if link.expired?
24
+
25
+ redirect_to link.url, status: :found, allow_other_host: true
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,14 +1,56 @@
1
- class DynamicLinks::V1::ShortLinksController < ApplicationController
2
- def create
3
- url = params.require(:url)
4
- # validate url
5
- # if !url_shortener.valid_url?(url)
6
- # render json: { error: 'invalid url' }, status: :bad_request
7
- # return
8
- # end
9
-
10
- # shorten url
11
- # save to db (not implemented yet)
12
- render json: DynamicLinks.generate_short_url(url), status: :created
1
+ module DynamicLinks
2
+ class V1::ShortLinksController < ApplicationController
3
+ before_action :check_rest_api_enabled
4
+
5
+ def create
6
+ url = params.require(:url)
7
+ client = DynamicLinks::Client.find_by({ api_key: params.require(:api_key) })
8
+
9
+ unless client
10
+ render json: { error: 'Invalid API key' }, status: :unauthorized
11
+ return
12
+ end
13
+
14
+ multi_tenant(client) do
15
+ render json: DynamicLinks.generate_short_url(url, client), status: :created
16
+ end
17
+ rescue DynamicLinks::InvalidURIError
18
+ render json: { error: 'Invalid URL' }, status: :bad_request
19
+ rescue => e
20
+ DynamicLinks::Logger.log_error(e)
21
+ render json: { error: 'An error occurred while processing your request' }, status: :internal_server_error
22
+ end
23
+
24
+ def expand
25
+ api_key = params.require(:api_key)
26
+ client = DynamicLinks::Client.find_by({ api_key: api_key })
27
+
28
+ unless client
29
+ render json: { error: 'Invalid API key' }, status: :unauthorized
30
+ return
31
+ end
32
+
33
+ multi_tenant(client) do
34
+ short_link = params.require(:short_url)
35
+ full_url = DynamicLinks.resolve_short_url(short_link)
36
+
37
+ if full_url
38
+ render json: { full_url: full_url }, status: :ok
39
+ else
40
+ render json: { error: 'Short link not found' }, status: :not_found
41
+ end
42
+ end
43
+ rescue => e
44
+ DynamicLinks::Logger.log_error(e)
45
+ render json: { error: 'An error occurred while processing your request' }, status: :internal_server_error
46
+ end
47
+
48
+ private
49
+
50
+ def check_rest_api_enabled
51
+ unless DynamicLinks.configuration.enable_rest_api
52
+ render json: { error: 'REST API feature is disabled' }, status: :forbidden
53
+ end
54
+ end
13
55
  end
14
56
  end
@@ -1,5 +1,5 @@
1
1
  module DynamicLinks
2
- # This job generates short links in the background
2
+ # This job generates short links in the background for KGS strategy
3
3
  # It is intended to be run periodically
4
4
  # We can find available short links by querying the database with query:
5
5
  # ShortenedUrl.where(available: true)
@@ -0,0 +1,31 @@
1
+ module DynamicLinks
2
+ # @author Saiqul Haq <saiqulhaq@gmail.com>
3
+ # This job is used to create a shortened url
4
+ class ShortenUrlJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ def perform(client, url, short_url, lock_key)
8
+ locker = DynamicLinks::Async::Locker.new
9
+ strategy = StrategyFactory.get_strategy(DynamicLinks.configuration.shortening_strategy)
10
+
11
+ begin
12
+ if strategy.always_growing?
13
+ storage.create!(client: client, url: url, short_url: short_url)
14
+ else
15
+ storage.find_or_create!(client, short_url, url)
16
+ end
17
+ locker.unlock(lock_key)
18
+ DynamicLinks::Logger.log_info("Lock key #{lock_key} deleted after ShortenUrlJob")
19
+ rescue => e
20
+ DynamicLinks::Logger.log_error("Error in ShortenUrlJob: #{e.message}")
21
+ raise e
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def storage
28
+ @storage ||= ShortenedUrl
29
+ end
30
+ end
31
+ end
@@ -15,7 +15,12 @@
15
15
  #
16
16
  module DynamicLinks
17
17
  class Client < ApplicationRecord
18
+ VALID_SCHEMES = ['http', 'https'].freeze
19
+
18
20
  validates :name, presence: true, uniqueness: true
19
21
  validates :api_key, presence: true, uniqueness: true
22
+ validates :hostname, presence: true, uniqueness: true
23
+ validates :scheme, presence: true, inclusion: { in: VALID_SCHEMES }
24
+ validates :hostname, format: { with: /\A[a-z0-9]([a-z0-9\-]{,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]{,61}[a-z0-9])?)*\z/i, message: 'must be a valid hostname' }
20
25
  end
21
26
  end
@@ -17,9 +17,27 @@
17
17
  #
18
18
  module DynamicLinks
19
19
  class ShortenedUrl < ApplicationRecord
20
- belongs_to :client, optional: true
20
+ belongs_to :client
21
+ multi_tenant :client if respond_to?(:multi_tenant)
21
22
 
22
23
  validates :url, presence: true
23
- validates :short_url, presence: true, uniqueness: true
24
+ validates :short_url, presence: true, uniqueness: { scope: :client_id }
25
+
26
+ def self.find_or_create!(client, short_url, url)
27
+ transaction do
28
+ record = find_or_create_by!(client: client, short_url: short_url) do |record|
29
+ record.url = url
30
+ end
31
+ record
32
+ end
33
+ rescue ActiveRecord::RecordInvalid => e
34
+ # Log the error and re-raise if needed or return a meaningful error message
35
+ DynamicLinks::Logger.log_error("ShortenedUrl creation failed: #{e.message}")
36
+ raise e
37
+ end
38
+
39
+ def expired?
40
+ expires_at&.past?
41
+ end
24
42
  end
25
43
  end
data/config/routes.rb CHANGED
@@ -2,7 +2,9 @@
2
2
  #
3
3
 
4
4
  DynamicLinks::Engine.routes.draw do
5
+ get '/:short_url', to: 'redirects#show', as: :shortened
5
6
  namespace :v1 do
6
7
  post "/shortLinks", to: "short_links#create", as: :short_links
8
+ get "/shortLinks/:short_url", to: "short_links#expand", as: :expand_short_link
7
9
  end
8
10
  end
@@ -3,11 +3,18 @@ class CreateDynamicLinksClients < ActiveRecord::Migration[7.1]
3
3
  create_table :dynamic_links_clients do |t|
4
4
  t.string :name, null: false
5
5
  t.string :api_key, null: false
6
+ t.string :scheme, default: 'https', null: false
7
+ t.string :hostname, null: false
6
8
 
7
9
  t.timestamps
8
10
  end
9
11
 
10
- add_index :dynamic_links_clients, :name, unique: true
12
+ if DynamicLinks.configuration.db_infra_strategy == :sharding
13
+ create_reference_table(:dynamic_links_clients)
14
+ end
15
+
16
+ add_index :dynamic_links_clients, :name
11
17
  add_index :dynamic_links_clients, :api_key, unique: true
18
+ add_index :dynamic_links_clients, :hostname, unique: true
12
19
  end
13
20
  end
@@ -7,15 +7,14 @@ class CreateDynamicLinksShortenedUrls < ActiveRecord::Migration[7.1]
7
7
  t.bigint :id, primary_key: true
8
8
  end
9
9
 
10
- t.references :client, null: true, foreign_key: { to_table: :dynamic_links_clients }, type: :bigint
10
+ t.references :client, foreign_key: { to_table: :dynamic_links_clients }, type: :bigint
11
+
11
12
  # 2083 is the maximum length of a URL according to the RFC 2616
12
13
  t.string :url, null: false, limit: 2083
13
14
  # 12 is the maximum length of a short URL if we use the RedisCounterStrategy
14
- t.string :short_url, null: false, limit: DynamicLinks::ShorteningStrategies::RedisCounterStrategy::MAX_LENGTH
15
+ t.string :short_url, null: false, limit: 20
15
16
  t.datetime :expires_at
16
17
  t.timestamps
17
18
  end
18
-
19
- add_index :dynamic_links_shortened_urls, :short_url, unique: true
20
19
  end
21
20
  end
@@ -0,0 +1,34 @@
1
+ class FixCitusIndex < ActiveRecord::Migration[7.1]
2
+ def up
3
+ if DynamicLinks.configuration.db_infra_strategy == :sharding
4
+ # execute SQL to remove primary key constraint
5
+ execute <<-SQL
6
+ ALTER TABLE dynamic_links_shortened_urls
7
+ DROP CONSTRAINT dynamic_links_shortened_urls_pkey;
8
+ SQL
9
+
10
+ execute <<-SQL
11
+ ALTER TABLE dynamic_links_shortened_urls
12
+ ADD PRIMARY KEY (id, client_id);
13
+ SQL
14
+ create_distributed_table :dynamic_links_shortened_urls, :client_id
15
+ end
16
+ end
17
+
18
+ # this code is untested
19
+ def down
20
+ if DynamicLinks.configuration.db_infra_strategy == :sharding
21
+ drop_distributed_table :dynamic_links_shortened_urls, :client_id
22
+
23
+ execute <<-SQL
24
+ ALTER TABLE dynamic_links_shortened_urls
25
+ DROP CONSTRAINT dynamic_links_shortened_urls_pkey;
26
+ SQL
27
+
28
+ execute <<-SQL
29
+ ALTER TABLE dynamic_links_shortened_urls
30
+ ADD PRIMARY KEY (id);
31
+ SQL
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,5 @@
1
+ class AddUniqueIndexToShortenedUrls < ActiveRecord::Migration[7.1]
2
+ def change
3
+ add_index :dynamic_links_shortened_urls, [:client_id, :short_url], unique: true
4
+ end
5
+ end
@@ -0,0 +1,60 @@
1
+ module DynamicLinks
2
+ module Async
3
+ # @author Saiqul Haq <saiqulhaq@gmail.com>
4
+ class Locker
5
+ LockAcquisitionError = Class.new(StandardError)
6
+ LockReleaseError = Class.new(StandardError)
7
+ attr_reader :cache_store
8
+
9
+ def initialize(cache_store = DynamicLinks.configuration.cache_store)
10
+ @cache_store = cache_store
11
+ end
12
+
13
+ def generate_lock_key(client, url)
14
+ "lock:shorten_url#{client.id}:#{url_to_lock_key(url)}"
15
+ end
16
+
17
+ def locked?(lock_key)
18
+ cache_store.exist?(lock_key)
19
+ end
20
+
21
+ # Acquires a lock for the given key and executes the block if lock is acquired.
22
+ # This method won't release the lock after block execution.
23
+ # We release the lock in the job after the job is done.
24
+ # @param [String] lock_key, it's better to use generate_lock_key method to generate lock_key
25
+ # @param [Integer] expires_in, default is 60 seconds
26
+ # @param [Block] block, the block to be executed if lock is acquired
27
+ # @return [Boolean]
28
+ def lock_if_absent(lock_key, expires_in: 60, &block)
29
+ is_locked = false
30
+ begin
31
+ is_locked = cache_store.increment(lock_key, 1, expires_in: expires_in) == 1
32
+ yield if is_locked && block_given?
33
+
34
+ unless is_locked
35
+ DynamicLinks::Logger.log_info "Unable to acquire lock for key: #{lock_key}"
36
+ end
37
+ rescue => e
38
+ DynamicLinks::Logger.log_error("Locking error: #{e.message}")
39
+ raise e
40
+ end
41
+
42
+ is_locked
43
+ end
44
+
45
+ # Deletes an entry in the cache. Returns true if an entry is deleted and false otherwise.
46
+ # @return [Boolean]
47
+ def unlock(lock_key)
48
+ deleted = cache_store.delete(lock_key)
49
+ raise LockReleaseError, "Unable to release lock for key: #{lock_key}" unless deleted
50
+ deleted
51
+ end
52
+
53
+ private
54
+
55
+ def url_to_lock_key(url)
56
+ Digest::SHA256.hexdigest(url)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -1,10 +1,99 @@
1
1
  module DynamicLinks
2
+ # @author Saiqul Haq <saiqulhaq@gmail.com>
2
3
  class Configuration
3
- attr_accessor :shortening_strategy
4
+ attr_reader :shortening_strategy, :enable_rest_api, :db_infra_strategy,
5
+ :async_processing, :redis_counter_config, :cache_store,
6
+ :enable_fallback_mode, :firebase_host
4
7
 
8
+ VALID_DB_INFRA_STRATEGIES = [:standard, :sharding].freeze
9
+
10
+ DEFAULT_SHORTENING_STRATEGY = :md5
11
+ DEFAULT_ENABLE_REST_API = true
12
+ DEFAULT_DB_INFRA_STRATEGY = :standard
13
+ DEFAULT_ASYNC_PROCESSING = false
14
+ DEFAULT_REDIS_COUNTER_CONFIG = RedisConfig.new
15
+ # use any class that extends ActiveSupport::Cache::Store, default is MemoryStore
16
+ DEFAULT_CACHE_STORE = ActiveSupport::Cache::MemoryStore.new
17
+ DEFAULT_ENABLE_FALLBACK_MODE = false
18
+ DEFAULT_FIREBASE_HOST = nil
19
+
20
+ # Usage:
21
+ # DynamicLinks.configure do |config|
22
+ # config.shortening_strategy = :md5 # or other strategy name, see StrategyFactory for available strategies
23
+ # config.enable_rest_api = true # or false. when false, the API requests will be rejected
24
+ # config.db_infra_strategy = :standard # or :sharding. if sharding is used, then xxx
25
+ # config.async_processing = false # or true. if true, the shortening process will be done asynchronously using ActiveJob
26
+ # config.redis_counter_config = RedisConfig.new # see RedisConfig documentation for more details
27
+ # # if you use Redis
28
+ # config.cache_store = ActiveSupport::Cache::RedisStore.new('redis://localhost:6379/0/cache')
29
+ # # if you use Memcached
30
+ # config.cache_store = ActiveSupport::Cache::MemCacheStore.new('localhost:11211')
31
+ # end
32
+ #
33
+ # @return [Configuration]
5
34
  def initialize
6
- @shortening_strategy = :MD5 # Default strategy
35
+ @shortening_strategy = DEFAULT_SHORTENING_STRATEGY
36
+ @enable_rest_api = DEFAULT_ENABLE_REST_API
37
+ @db_infra_strategy = DEFAULT_DB_INFRA_STRATEGY
38
+ @async_processing = DEFAULT_ASYNC_PROCESSING
39
+ # config for RedisCounterStrategy
40
+ @redis_counter_config = DEFAULT_REDIS_COUNTER_CONFIG
41
+ @cache_store = DEFAULT_CACHE_STORE
42
+ @enable_fallback_mode = DEFAULT_ENABLE_FALLBACK_MODE
43
+ @firebase_host = DEFAULT_FIREBASE_HOST
44
+ end
45
+
46
+ def shortening_strategy=(strategy)
47
+ raise ArgumentError, "Invalid shortening strategy" unless StrategyFactory::VALID_SHORTENING_STRATEGIES.include?(strategy)
48
+ @shortening_strategy = strategy
49
+ end
50
+
51
+ def enable_rest_api=(value)
52
+ raise ArgumentError, "enable_rest_api must be a boolean" unless [true, false].include?(value)
53
+ @enable_rest_api = value
54
+ end
55
+
56
+ def db_infra_strategy=(strategy)
57
+ raise ArgumentError, "Invalid DB infra strategy" unless VALID_DB_INFRA_STRATEGIES.include?(strategy)
58
+ @db_infra_strategy = strategy
59
+ end
60
+
61
+ def async_processing=(value)
62
+ raise ArgumentError, "async_processing must be a boolean" unless [true, false].include?(value)
63
+ @async_processing = value
64
+ end
65
+
66
+ def redis_counter_config=(config)
67
+ raise ArgumentError, "redis_counter_config must be an instance of RedisConfig" unless config.is_a?(RedisConfig)
68
+ @redis_counter_config = config
69
+ end
70
+
71
+ def cache_store=(store)
72
+ raise ArgumentError, "cache_store must be an instance of ActiveSupport::Cache::Store" unless store.is_a?(ActiveSupport::Cache::Store)
73
+ @cache_store = store
74
+ end
75
+
76
+ def enable_fallback_mode=(value)
77
+ raise ArgumentError, "enable_fallback_mode must be a boolean" unless [true, false].include?(value)
78
+ @enable_fallback_mode = value
79
+ end
80
+
81
+ def firebase_host=(host)
82
+ # allow nil or blank host (optional, depends on your app logic)
83
+ if host.nil? || host.strip.empty?
84
+ @firebase_host = nil
85
+ return
86
+ end
87
+
88
+ begin
89
+ uri = URI.parse(host.to_s)
90
+ valid = uri.is_a?(URI::HTTP) && uri.host.present?
91
+ raise unless valid
92
+ rescue
93
+ raise ArgumentError, "firebase_host must be a valid URL with a host"
94
+ end
95
+
96
+ @firebase_host = host
7
97
  end
8
98
  end
9
99
  end
10
-
@@ -0,0 +1,6 @@
1
+ module DynamicLinks
2
+ class InvalidURIError < ::URI::InvalidURIError; end
3
+ class ConfigurationError < StandardError; end
4
+ class MissingDependency < LoadError; end
5
+ class ShorteningFailed < StandardError; end
6
+ end
@@ -0,0 +1,32 @@
1
+ module DynamicLinks
2
+ # @author Saiqul Haq <saiqulhaq@gmail.com>
3
+ class Logger
4
+ def self.instance
5
+ @logger ||= Rails.logger
6
+ end
7
+
8
+ def self.log_info(message)
9
+ instance.info(message)
10
+ end
11
+
12
+ def self.log_error(message)
13
+ instance.error(message)
14
+ end
15
+
16
+ def self.log_warn(message)
17
+ instance.warn(message)
18
+ end
19
+
20
+ def self.log_debug(message)
21
+ instance.debug(message)
22
+ end
23
+
24
+ def self.log_fatal(message)
25
+ instance.fatal(message)
26
+ end
27
+
28
+ def self.log_unknown(message)
29
+ instance.unknown(message)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,22 @@
1
+ # @author Saiqul Haq <saiqulhaq@gmail.com>
2
+
3
+ module DynamicLinks
4
+ # RedisConfig is a class to hold Redis configuration
5
+ class RedisConfig
6
+ attr_accessor :config, :pool_size, :pool_timeout
7
+
8
+ # @param [Hash] config
9
+ # Default to an empty hash, can be overridden
10
+ # config = {
11
+ # host: 'localhost',
12
+ # port: 6379
13
+ # }
14
+ # @param [Integer] pool_size Default to 5, can be overridden
15
+ # @param [Integer] pool_timeout Default to 5, can be overridden
16
+ def initialize(config = {}, pool_size = 5, pool_timeout = 5)
17
+ @config = config
18
+ @pool_size = pool_size
19
+ @pool_timeout = pool_timeout
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,52 @@
1
+ module DynamicLinks
2
+ # @author Saiqul Haq <saiqulhaq@gmail.com>
3
+ class Shortener
4
+ attr_reader :locker, :strategy, :storage, :async_worker
5
+
6
+ def initialize(locker: DynamicLinks::Async::Locker.new,
7
+ strategy: StrategyFactory.get_strategy(DynamicLinks.configuration.shortening_strategy),
8
+ storage: ShortenedUrl,
9
+ async_worker: ShortenUrlJob)
10
+ @locker = locker
11
+ @strategy = strategy
12
+ @storage = storage
13
+ @async_worker = async_worker
14
+ end
15
+
16
+ # @param client [Client] the client that owns the url
17
+ # @param url [String] the url to be shortened
18
+ # @return [String] the shortened url
19
+ def shorten(client, url)
20
+ short_url = strategy.shorten(url)
21
+
22
+ if strategy.always_growing?
23
+ storage.create!(client: client, url: url, short_url: short_url)
24
+ else
25
+ storage.find_or_create!(client, short_url, url)
26
+ end
27
+ URI::Generic.build({scheme: client.scheme, host: client.hostname, path: "/#{short_url}"}).to_s
28
+ rescue => e
29
+ DynamicLinks::Logger.log_error("Error shortening URL: #{e.message}")
30
+ raise e
31
+ end
32
+
33
+ # @param client [Client] the client that owns the url
34
+ # @param url [String] the url to be shortened
35
+ def shorten_async(client, url)
36
+ lock_key = locker.generate_lock_key(client, url)
37
+
38
+ locker.lock_if_absent(lock_key) do
39
+ short_url = strategy.shorten(url)
40
+ content = {
41
+ url: url,
42
+ short_url: short_url
43
+ }
44
+
45
+ async_worker.perform_later(client, url, short_url, lock_key)
46
+ end
47
+ rescue => e
48
+ DynamicLinks::Logger.log_error("Error shortening URL asynchronously: #{e.message}")
49
+ raise e
50
+ end
51
+ end
52
+ end
@@ -9,6 +9,12 @@ module DynamicLinks
9
9
  raise NotImplementedError, "You must implement the shorten method"
10
10
  end
11
11
 
12
+ # Determines if the strategy always generates a new shortened URL
13
+ # @return [Boolean]
14
+ def always_growing?
15
+ false # Default behavior is not to always grow
16
+ end
17
+
12
18
  private
13
19
 
14
20
  # Convert an integer into a Base62 string
@@ -3,17 +3,15 @@ module DynamicLinks
3
3
  # Shortens the given URL using Nano ID
4
4
  # This strategy will generate a different short URL for the same given URL
5
5
  class NanoIDStrategy < BaseStrategy
6
- begin
7
- require 'nanoid'
8
- rescue LoadError
9
- raise 'Missing dependency: Please add "nanoid" to your Gemfile to use NanoIdStrategy.'
10
- end
11
-
12
6
  # Shortens the given URL using Nano ID
13
7
  # @param url [String] The URL to shorten (not directly used in Nano ID strategy)
14
- # @param size [Integer] The size (length) of the generated Nano ID
15
- def shorten(url, size: MIN_LENGTH)
16
- ::Nanoid.generate(size: size)
8
+ # @param min_length [Integer] The size (length) of the generated Nano ID
9
+ def shorten(url, min_length: MIN_LENGTH)
10
+ ::Nanoid.generate(size: min_length)
11
+ end
12
+
13
+ def always_growing?
14
+ true # This strategy always generates a new shortened URL
17
15
  end
18
16
  end
19
17
  end
@@ -1,29 +1,40 @@
1
-
2
1
  module DynamicLinks
3
2
  module ShorteningStrategies
3
+ # usage:
4
+ # Using default configuration from DynamicLinks configuration
5
+ # default_strategy = DynamicLinks::ShorteningStrategies::RedisCounterStrategy.new
6
+ #
7
+ # Using a custom configuration
8
+ # custom_redis_config = { host: 'custom-host', port: 6380 }
9
+ # custom_strategy = DynamicLinks::ShorteningStrategies::RedisCounterStrategy.new(custom_redis_config)
4
10
  class RedisCounterStrategy < BaseStrategy
5
- begin
6
- require 'redis'
7
- rescue LoadError
8
- raise 'Missing dependency: Please add "redis" to your Gemfile to use RedisCounterStrategy.'
9
- end
10
-
11
11
  MIN_LENGTH = 12
12
12
  REDIS_COUNTER_KEY = "dynamic_links:counter".freeze
13
13
 
14
- def initialize
15
- # TODO: use pool of connections
16
- @redis = Redis.new
14
+ # @param redis_config [Hash]
15
+ def initialize(redis_config = nil)
16
+ super()
17
+
18
+ configuration = redis_config.nil? ? DynamicLinks.configuration.redis_counter_config : DynamicLinks::Configuration::RedisConfig.new(redis_config)
19
+ @redis = ConnectionPool.new(size: configuration.pool_size, timeout: configuration.pool_timeout) do
20
+ Redis.new(configuration.config)
21
+ end
22
+ end
23
+
24
+ def always_growing?
25
+ true # This strategy always generates a new shortened URL
17
26
  end
18
27
 
19
28
  # Shortens the given URL using a Redis counter
20
29
  # @param url [String] The URL to shorten
21
30
  # @return [String] The shortened URL, 12 characters long
22
31
  def shorten(url, min_length: MIN_LENGTH)
23
- counter = @redis.incr(REDIS_COUNTER_KEY)
24
-
25
- short_url = base62_encode("#{counter}#{url.hash.abs}".to_i)
26
- short_url.ljust(min_length, '0')
32
+ @redis.with do |conn|
33
+ counter = conn.incr(REDIS_COUNTER_KEY)
34
+ short_url = base62_encode("#{counter}#{url.hash.abs}".to_i)
35
+ short_url = short_url.ljust(min_length, '0')
36
+ short_url
37
+ end
27
38
  end
28
39
  end
29
40
  end
@@ -1,5 +1,8 @@
1
1
  module DynamicLinks
2
2
  class StrategyFactory
3
+ VALID_SHORTENING_STRATEGIES = [:md5, :sha256, :crc32,
4
+ :nano_id, :redis_counter, :mock].freeze
5
+
3
6
  def self.get_strategy(strategy_name)
4
7
  case strategy_name
5
8
  when :md5
@@ -9,8 +12,10 @@ module DynamicLinks
9
12
  when :crc32
10
13
  ShorteningStrategies::CRC32Strategy.new
11
14
  when :nano_id
12
- ShorteningStrategies::NanoIdStrategy.new
15
+ ensure_nanoid_available
16
+ ShorteningStrategies::NanoIDStrategy.new
13
17
  when :redis_counter
18
+ ensure_redis_available
14
19
  ShorteningStrategies::RedisCounterStrategy.new
15
20
  when :mock
16
21
  ShorteningStrategies::MockStrategy.new
@@ -18,6 +23,28 @@ module DynamicLinks
18
23
  raise "Unknown strategy: #{strategy_name}"
19
24
  end
20
25
  end
26
+
27
+ def self.ensure_nanoid_available
28
+ begin
29
+ require 'nanoid'
30
+ rescue LoadError
31
+ raise 'Missing dependency: Please add "nanoid" to your Gemfile to use NanoIdStrategy.'
32
+ end
33
+ end
34
+
35
+ def self.ensure_redis_available
36
+ begin
37
+ require 'redis'
38
+ rescue LoadError
39
+ raise 'Missing dependency: Please add "redis" to your Gemfile to use RedisCounterStrategy.'
40
+ end
41
+
42
+ begin
43
+ require 'connection_pool'
44
+ rescue LoadError
45
+ raise 'Missing dependency: Please add "connection_pool" to your Gemfile to use RedisCounterStrategy.'
46
+ end
47
+ end
21
48
  end
22
49
  end
23
50
 
@@ -0,0 +1,14 @@
1
+ module DynamicLinks
2
+ class Validator
3
+ # Validates if the given URL is a valid HTTP or HTTPS URL
4
+ # @param url [String] The URL to validate
5
+ # @return [Boolean] true if valid, false otherwise
6
+ def self.valid_url?(url)
7
+ uri = URI.parse(url)
8
+ uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
9
+ rescue URI::InvalidURIError
10
+ false
11
+ end
12
+ end
13
+ end
14
+
@@ -1,3 +1,3 @@
1
1
  module DynamicLinks
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/dynamic_links.rb CHANGED
@@ -1,5 +1,31 @@
1
+ # @author Saiqul Haq <saiqulhaq@gmail.com>
2
+
3
+ if ENV['RAILS_ENV'] == 'test'
4
+ require 'simplecov'
5
+
6
+ SimpleCov.start do
7
+ load_profile "test_frameworks"
8
+
9
+ add_filter %r{^/config/}
10
+ add_filter %r{^/db/}
11
+
12
+ add_group "Controllers", "app/controllers"
13
+ add_group "Channels", "app/channels"
14
+ add_group "Models", "app/models"
15
+ add_group "Mailers", "app/mailers"
16
+ add_group "Helpers", "app/helpers"
17
+ add_group "Jobs", %w[app/jobs app/workers]
18
+ add_group "DynamicLinks", "lib/"
19
+ end
20
+ end
21
+
1
22
  require "dynamic_links/version"
2
23
  require "dynamic_links/engine"
24
+ require "dynamic_links/logger"
25
+ require "dynamic_links/error_classes"
26
+ require "dynamic_links/redis_config"
27
+ require "dynamic_links/configuration"
28
+ require "dynamic_links/validator"
3
29
  require "dynamic_links/strategy_factory"
4
30
  require "dynamic_links/shortening_strategies/base_strategy"
5
31
  require "dynamic_links/shortening_strategies/sha256_strategy"
@@ -8,7 +34,8 @@ require "dynamic_links/shortening_strategies/crc32_strategy"
8
34
  require "dynamic_links/shortening_strategies/nano_id_strategy"
9
35
  require "dynamic_links/shortening_strategies/redis_counter_strategy"
10
36
  require "dynamic_links/shortening_strategies/mock_strategy"
11
- require "dynamic_links/configuration"
37
+ require "dynamic_links/async/locker"
38
+ require "dynamic_links/shortener"
12
39
 
13
40
  module DynamicLinks
14
41
  class << self
@@ -23,26 +50,20 @@ module DynamicLinks
23
50
  end
24
51
  end
25
52
 
26
- def self.shorten_url(url)
27
- strategy_key = configuration.shortening_strategy
28
-
29
- begin
30
- strategy = StrategyFactory.get_strategy(strategy_key)
31
- rescue RuntimeError => e
32
- # This will catch the 'Unknown strategy' error from the factory
33
- raise "Invalid shortening strategy: #{strategy_key}. Error: #{e.message}"
34
- rescue ArgumentError
35
- raise "#{strategy_key} strategy needs to be initialized with arguments"
36
- rescue => e
37
- raise "Unexpected error while initializing the strategy: #{e.message}"
38
- end
53
+ def self.shorten_url(url, client, async: DynamicLinks.configuration.async_processing)
54
+ raise InvalidURIError, 'Invalid URL' unless Validator.valid_url?(url)
39
55
 
40
- strategy.shorten(url)
56
+ shortener = Shortener.new
57
+ if async
58
+ shortener.shorten_async(client, url)
59
+ else
60
+ shortener.shorten(client, url)
61
+ end
41
62
  end
42
63
 
43
64
  # mimic Firebase Dynamic Links API
44
- def self.generate_short_url(original_url)
45
- short_link = shorten_url(original_url)
65
+ def self.generate_short_url(original_url, client)
66
+ short_link = shorten_url(original_url, client)
46
67
 
47
68
  {
48
69
  shortLink: short_link,
@@ -50,4 +71,8 @@ module DynamicLinks
50
71
  warning: []
51
72
  }
52
73
  end
74
+
75
+ def self.resolve_short_url(short_link)
76
+ DynamicLinks::ShortenedUrl.find_by(short_url: short_link)&.url
77
+ end
53
78
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dynamic_links
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Saiqul Haq
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-01-01 00:00:00.000000000 Z
11
+ date: 2025-06-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -17,6 +17,9 @@ dependencies:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '5'
20
+ - - "<="
21
+ - !ruby/object:Gem::Version
22
+ version: '8'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
@@ -24,20 +27,29 @@ dependencies:
24
27
  - - ">="
25
28
  - !ruby/object:Gem::Version
26
29
  version: '5'
30
+ - - "<="
31
+ - !ruby/object:Gem::Version
32
+ version: '8'
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: nanoid
29
35
  requirement: !ruby/object:Gem::Requirement
30
36
  requirements:
31
- - - "~>"
37
+ - - ">="
32
38
  - !ruby/object:Gem::Version
33
- version: '2.0'
39
+ version: '2'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '3'
34
43
  type: :development
35
44
  prerelease: false
36
45
  version_requirements: !ruby/object:Gem::Requirement
37
46
  requirements:
38
- - - "~>"
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '2'
50
+ - - "<"
39
51
  - !ruby/object:Gem::Version
40
- version: '2.0'
52
+ version: '3'
41
53
  - !ruby/object:Gem::Dependency
42
54
  name: redis
43
55
  requirement: !ruby/object:Gem::Requirement
@@ -45,6 +57,9 @@ dependencies:
45
57
  - - ">="
46
58
  - !ruby/object:Gem::Version
47
59
  version: '4'
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '6'
48
63
  type: :development
49
64
  prerelease: false
50
65
  version_requirements: !ruby/object:Gem::Requirement
@@ -52,34 +67,63 @@ dependencies:
52
67
  - - ">="
53
68
  - !ruby/object:Gem::Version
54
69
  version: '4'
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '6'
55
73
  - !ruby/object:Gem::Dependency
56
74
  name: annotate
57
75
  requirement: !ruby/object:Gem::Requirement
58
76
  requirements:
59
77
  - - ">="
60
78
  - !ruby/object:Gem::Version
61
- version: '0'
79
+ version: '3'
80
+ - - "<"
81
+ - !ruby/object:Gem::Version
82
+ version: '4'
62
83
  type: :development
63
84
  prerelease: false
64
85
  version_requirements: !ruby/object:Gem::Requirement
65
86
  requirements:
66
87
  - - ">="
67
88
  - !ruby/object:Gem::Version
68
- version: '0'
89
+ version: '3'
90
+ - - "<"
91
+ - !ruby/object:Gem::Version
92
+ version: '4'
93
+ - !ruby/object:Gem::Dependency
94
+ name: timecop
95
+ requirement: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - "<"
98
+ - !ruby/object:Gem::Version
99
+ version: '1'
100
+ type: :development
101
+ prerelease: false
102
+ version_requirements: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - "<"
105
+ - !ruby/object:Gem::Version
106
+ version: '1'
69
107
  - !ruby/object:Gem::Dependency
70
108
  name: dotenv-rails
71
109
  requirement: !ruby/object:Gem::Requirement
72
110
  requirements:
73
111
  - - ">="
74
112
  - !ruby/object:Gem::Version
75
- version: '0'
113
+ version: '2'
114
+ - - "<"
115
+ - !ruby/object:Gem::Version
116
+ version: '3'
76
117
  type: :development
77
118
  prerelease: false
78
119
  version_requirements: !ruby/object:Gem::Requirement
79
120
  requirements:
80
121
  - - ">="
81
122
  - !ruby/object:Gem::Version
82
- version: '0'
123
+ version: '2'
124
+ - - "<"
125
+ - !ruby/object:Gem::Version
126
+ version: '3'
83
127
  description: Rails engine to shorten any URL with custom domain.
84
128
  email:
85
129
  - saiqulhaq@gmail.com
@@ -87,15 +131,19 @@ executables: []
87
131
  extensions: []
88
132
  extra_rdoc_files: []
89
133
  files:
134
+ - CHANGELOG.md
135
+ - MIT-LICENSE
90
136
  - README.md
91
137
  - Rakefile
92
138
  - app/assets/config/dynamic_links_manifest.js
93
139
  - app/assets/stylesheets/dynamic_links/application.css
94
140
  - app/controllers/dynamic_links/application_controller.rb
141
+ - app/controllers/dynamic_links/redirects_controller.rb
95
142
  - app/controllers/dynamic_links/v1/short_links_controller.rb
96
143
  - app/helpers/dynamic_links/application_helper.rb
97
144
  - app/jobs/dynamic_links/application_job.rb
98
145
  - app/jobs/dynamic_links/generate_short_links_job.rb
146
+ - app/jobs/dynamic_links/shorten_url_job.rb
99
147
  - app/mailers/dynamic_links/application_mailer.rb
100
148
  - app/models/dynamic_links/application_record.rb
101
149
  - app/models/dynamic_links/client.rb
@@ -104,9 +152,16 @@ files:
104
152
  - config/routes.rb
105
153
  - db/migrate/20231228165744_create_dynamic_links_clients.rb
106
154
  - db/migrate/20231228175142_create_dynamic_links_shortened_urls.rb
155
+ - db/migrate/20240128030329_fix_citus_index.rb
156
+ - db/migrate/20240128030419_add_unique_index_to_shortened_urls.rb
107
157
  - lib/dynamic_links.rb
158
+ - lib/dynamic_links/async/locker.rb
108
159
  - lib/dynamic_links/configuration.rb
109
160
  - lib/dynamic_links/engine.rb
161
+ - lib/dynamic_links/error_classes.rb
162
+ - lib/dynamic_links/logger.rb
163
+ - lib/dynamic_links/redis_config.rb
164
+ - lib/dynamic_links/shortener.rb
110
165
  - lib/dynamic_links/shortening_strategies/base_strategy.rb
111
166
  - lib/dynamic_links/shortening_strategies/crc32_strategy.rb
112
167
  - lib/dynamic_links/shortening_strategies/md5_strategy.rb
@@ -115,17 +170,19 @@ files:
115
170
  - lib/dynamic_links/shortening_strategies/redis_counter_strategy.rb
116
171
  - lib/dynamic_links/shortening_strategies/sha256_strategy.rb
117
172
  - lib/dynamic_links/strategy_factory.rb
173
+ - lib/dynamic_links/validator.rb
118
174
  - lib/dynamic_links/version.rb
119
175
  - lib/generators/dynamic_links/add_kgs_migration_generator.rb
120
176
  - lib/generators/dynamic_links/templates/migration_template.rb
121
177
  - lib/tasks/dynamic_links_tasks.rake
122
178
  homepage: https://saiqulhaq.id/dynamic_links
123
- licenses: []
179
+ licenses:
180
+ - MIT
124
181
  metadata:
125
182
  homepage_uri: https://saiqulhaq.id/dynamic_links
126
183
  source_code_uri: https://github.com/saiqulhaq/dynamic_links
127
184
  changelog_uri: https://github.com/saiqulhaq/dynamic_links/blob/master/CHANGELOG.md
128
- post_install_message:
185
+ post_install_message:
129
186
  rdoc_options: []
130
187
  require_paths:
131
188
  - lib
@@ -133,15 +190,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
133
190
  requirements:
134
191
  - - ">="
135
192
  - !ruby/object:Gem::Version
136
- version: '0'
193
+ version: '2.7'
137
194
  required_rubygems_version: !ruby/object:Gem::Requirement
138
195
  requirements:
139
196
  - - ">="
140
197
  - !ruby/object:Gem::Version
141
198
  version: '0'
142
199
  requirements: []
143
- rubygems_version: 3.1.2
144
- signing_key:
200
+ rubygems_version: 3.5.22
201
+ signing_key:
145
202
  specification_version: 4
146
203
  summary: Alternative to Firebase Dynamic Links feature
147
204
  test_files: []