link-canonical-0.1.0.0: docs/architecture/v1.md
# Haskell Link Normalizer – Technical Specification v1
## 1. Problem Statement
Modern applications ingest URLs from many sources (browsers, mobile apps, email clients, social media, short-link services). These URLs often differ syntactically while referring to the same underlying resource.
Examples of instability:
* Tracking parameters (`utm_*`, `gclid`, `fbclid`)
* Redirect chains (URL shorteners)
* Domain-specific variants (YouTube, Amazon, GitHub, Twitter/X)
* Encoding variations (`%2F` vs `/`)
* Port and scheme inconsistencies
**Goal**: Build a reusable Haskell library that converts arbitrary URLs into *canonical, semantically stable identifiers*.
---
## 2. Non-Goals
* Crawling page content
* SEO-style canonical discovery (`<link rel="canonical">`)
* Browser automation or JavaScript execution
* Meta-refresh redirect handling
The library operates **only on URLs and HTTP metadata**.
---
## 3. Semantic Model: What is "Canonical"?
This library defines canonicalization as **resource identity**, not content identity or experience identity.
| Level | Example | Same? |
|-------|---------|-------|
| Resource identity | `youtube.com/watch?v=X` and `youtube.com/watch?v=X&t=30` | Yes |
| Content identity | Same video at different timestamps | No |
| Experience identity | `youtube.com/watch?v=X` and `youtube.com/embed/X` | No |
**This library targets resource identity.** Two URLs are canonical equivalents if they reference the same underlying resource, even if the presentation or position within that resource differs.
Implications:
* Timestamps (`t=`, `start=`) are stripped
* Embed variants are normalized to watch URLs
* Mobile/desktop variants are unified
---
## 4. Core Design Principles
1. **Separation of concerns**
* Redirect resolution (effectful)
* URL normalization (pure)
* Domain semantics (pure, pluggable)
2. **Determinism**
* Same input URL → same canonical output
* Query parameters are sorted alphabetically
3. **Bounded effects**
* Redirect depth limits (default: 10)
* Loop detection
* Timeout enforcement
4. **Extensibility**
* Domain rules as independent modules
* Configuration-driven rules
5. **Security by default**
* Private IP rejection
* Scheme restrictions
* No downgrade attacks
---
## 5. Technology Choices
### URI Library: `modern-uri`
We use `Text.URI` from the `modern-uri` package:
* Megaparsec-based parsing with good error messages
* Text-based (fits domain rule implementations)
* Well-designed API for URI manipulation
* Active maintenance
```haskell
import Text.URI (URI)
import qualified Text.URI as URI
```
### HTTP Client
The library is HTTP-client agnostic through a typeclass abstraction, but provides a default implementation using `http-client-tls`.
### GHC and Extensions
**Language**: GHC2024
**Default Extensions** (in cabal `common` stanza):
```cabal
default-language: GHC2024
default-extensions:
DeriveAnyClass
DuplicateRecordFields
NoFieldSelectors
OverloadedLabels
OverloadedStrings
StrictData
```
These extensions enable:
- `DuplicateRecordFields`: No field name prefixes required
- `NoFieldSelectors`: Prevents auto-generation of field accessor functions (use lenses instead)
- `OverloadedLabels`: Enables `#fieldName` syntax for generic-lens
- `StrictData`: All fields strict by default
---
## 6. High-Level Pipeline
```
Raw URL (Text)
│
├─ 1. Parse (Text → URI)
│
├─ 2. Validate scheme (http/https only)
│
├─ 3. Resolve redirects (IO, bounded)
│ ├─ Loop detection
│ ├─ Private IP rejection
│ └─ Max depth enforcement
│
├─ 4. Generic normalization (pure)
│ ├─ Lowercase scheme/host
│ ├─ Remove default ports
│ ├─ Normalize percent-encoding
│ ├─ Normalize path
│ ├─ Sort query parameters
│ ├─ Strip tracking parameters
│ └─ Remove fragments
│
├─ 5. Domain-specific normalization (pure)
│ └─ First matching rule wins
│
└─ 6. Final generic pass (pure)
└─ Re-apply tracking param removal
↓
Canonical URI
```
---
## 7. Public API Surface
### Layered API Design
```haskell
-- Layer 1: Pure normalization (no IO)
normalizeUri :: NormConfig -> URI -> URI
-- Layer 2: With redirect resolution
normalizeLink
:: (MonadIO m, MonadCatch m)
=> NormConfig
-> HttpClient m
-> URI
-> m (Either NormError NormResult)
-- Layer 3: Convenient defaults
normalizeWithDefaults
:: (MonadIO m, MonadCatch m)
=> URI
-> m (Either NormError URI)
```
### Supporting APIs
```haskell
-- Pure operations
applyDomainRules :: [DomainRule] -> URI -> URI
stripTracking :: TrackingConfig -> URI -> URI
normalizeEncoding :: URI -> URI
-- Effectful operations
resolveFinalUri
:: (MonadIO m, MonadCatch m)
=> HttpClient m
-> RedirectConfig
-> URI
-> m (Either RedirectError URI)
```
---
## 8. Package Prelude
The package provides a custom prelude at `Link.Canonical.Prelude` that:
- Re-exports common types and functions
- Enables `#fieldName` syntax via `generic-lens`
- Avoids repetitive imports across modules
```haskell
module Link.Canonical.Prelude
( module X
, module Control.Lens
)
where
-- Enable #label syntax for Generic records
import "generic-lens" Data.Generics.Labels ()
-- Re-export lens operators
import "lens" Control.Lens
-- Common re-exports
import Control.Monad as X (forM, forM_, unless, when)
import Control.Monad.IO.Class as X (MonadIO, liftIO)
import Data.Maybe as X (fromMaybe, isJust, isNothing, mapMaybe)
import Data.Set as X (Set)
import Data.Text as X (Text)
import Data.Time as X (NominalDiffTime, UTCTime)
import GHC.Generics as X (Generic)
import Text.URI as X (URI)
```
**Usage**: Internal modules import only the prelude:
```haskell
module Link.Canonical.Normalize where
import Link.Canonical.Prelude
```
---
## 9. Configuration
### Main Configuration Record
```haskell
data NormConfig = NormConfig
{ redirects :: RedirectConfig
, tracking :: TrackingConfig
, domainRules :: [DomainRule]
, stripFragment :: Bool -- default: True
, sortParams :: Bool -- default: True
, trailingSlash :: TrailingSlash -- default: Strip
}
deriving stock (Generic)
data TrailingSlash = Keep | Strip | Add
deriving stock (Generic, Eq, Show)
defaultConfig :: NormConfig
```
### Redirect Configuration
```haskell
data RedirectConfig = RedirectConfig
{ maxRedirects :: Int -- default: 10
, timeout :: NominalDiffTime -- default: 10s
, allowDowngrade :: Bool -- default: False (https→http)
, blockPrivateIPs :: Bool -- default: True
, userAgent :: Text -- default: "link-canonical/1.0"
}
deriving stock (Generic)
```
### Tracking Parameter Configuration
```haskell
data TrackingConfig = TrackingConfig
{ denyPatterns :: [Text] -- e.g., ["utm_*", "gclid"]
, allowlist :: Set Text -- params to never strip
}
deriving stock (Generic)
defaultTrackingParams :: [Text]
defaultTrackingParams =
[ "utm_*" -- Google Analytics
, "gclid" -- Google Ads
, "fbclid" -- Facebook
, "mc_*" -- Mailchimp
, "oly_*" -- Omeda
, "_ga" -- Google Analytics
, "_gl" -- Google Linker
, "ref" -- Generic referral (configurable)
, "source" -- Generic source (configurable)
, "msclkid" -- Microsoft Ads
, "dclid" -- DoubleClick
, "zanpid" -- Zanox
, "igshid" -- Instagram
, "si" -- Spotify
]
```
### Configuration with Lenses
All configuration updates use generic-lens:
```haskell
-- Customize redirect settings
customConfig :: NormConfig
customConfig = defaultConfig
& #redirects . #maxRedirects .~ 5
& #redirects . #timeout .~ 30
& #tracking . #allowlist .~ Set.fromList ["ref"]
```
---
## 10. Error Model
### Error Types
```haskell
data NormError
= ParseError Text
| UnsupportedScheme Text
| RedirectError RedirectError
| DomainRuleError Text
deriving stock (Generic, Eq, Show)
data RedirectError
= TooManyRedirects Int
| RedirectLoop [URI]
| RedirectTimeout
| PrivateIPBlocked URI
| SchemeDowngrade URI URI
| ConnectionFailed Text
| InvalidLocation Text
| HttpError Status
deriving stock (Generic, Eq, Show)
```
### Error Handling Philosophy
* Parse errors: Unrecoverable, return `Left`
* Network errors: Unrecoverable, return `Left`
* Invalid redirects: Stop following, use last valid URI
* Domain rule failures: Log and continue (best-effort)
---
## 11. HTTP Client Abstraction
### Typeclass Definition
```haskell
class Monad m => HttpClient m where
-- HEAD request for efficiency (no body needed for redirects)
headRequest :: URI -> m (Either HttpClientError HttpResponse)
data HttpResponse = HttpResponse
{ status :: Status
, location :: Maybe URI -- Parsed Location header
, headers :: [(Text, Text)]
}
deriving stock (Generic, Eq, Show)
data HttpClientError
= ConnectionError Text
| TimeoutError
| TlsError Text
| InvalidUri Text
deriving stock (Generic, Eq, Show)
```
### Default Implementation
```haskell
-- Using http-client-tls
newtype HttpClientIO a = HttpClientIO { runHttpClientIO :: Manager -> IO a }
deriving stock (Generic)
instance HttpClient HttpClientIO where
headRequest = httpClientIOHead
```
### Why HEAD Requests?
* Redirect resolution only needs headers
* Saves bandwidth (no response body)
* Faster for redirect chains
* Politer to servers
---
## 12. Redirect Resolution
### Algorithm
```haskell
resolveFinalUri :: HttpClient m => RedirectConfig -> URI -> m (Either RedirectError URI)
resolveFinalUri config uri = go Set.empty 0 uri
where
go visited depth current
| depth > config ^. #maxRedirects = Left (TooManyRedirects depth)
| current `Set.member` visited = Left (RedirectLoop $ Set.toList visited)
| isPrivateIP current && config ^. #blockPrivateIPs = Left (PrivateIPBlocked current)
| otherwise = do
response <- headRequest current
case response of
Left err -> Left (ConnectionFailed $ show err)
Right hr -> case hr ^. #status of
s | isRedirect s -> case hr ^. #location of
Nothing -> Left (InvalidLocation "Missing Location header")
Just loc ->
let next = resolveRelative current loc
in if isDowngrade current next && not (config ^. #allowDowngrade)
then Left (SchemeDowngrade current next)
else go (Set.insert current visited) (depth + 1) next
s | isSuccess s -> Right current
s -> Left (HttpError s)
```
### Private IP Detection
Block redirects to:
* `127.0.0.0/8` (loopback)
* `10.0.0.0/8` (private)
* `172.16.0.0/12` (private)
* `192.168.0.0/16` (private)
* `169.254.0.0/16` (link-local)
* `::1` (IPv6 loopback)
* `fc00::/7` (IPv6 private)
---
## 13. Generic URL Normalization (Pure)
### Operations (in order)
1. **Scheme normalization**
* Lowercase: `HTTP` → `http`
* Only `http` and `https` allowed
2. **Host normalization**
* Lowercase: `Example.COM` → `example.com`
* IDN to ASCII (punycode): `münchen.de` → `xn--mnchen-3ya.de`
* Remove trailing dot: `example.com.` → `example.com`
3. **Port normalization**
* Remove default ports: `http://example.com:80` → `http://example.com`
* Remove default ports: `https://example.com:443` → `https://example.com`
4. **Path normalization**
* Remove dot segments: `/a/./b/../c` → `/a/c`
* Ensure leading slash: `example.com` → `example.com/`
* Trailing slash policy (configurable)
5. **Percent-encoding normalization**
* Uppercase hex digits: `%2f` → `%2F`
* Decode unreserved characters: `%41` → `A`
* Encode reserved characters consistently
6. **Query parameter normalization**
* Sort alphabetically by key (for determinism)
* Remove empty `?`: `example.com?` → `example.com`
* Strip tracking parameters
7. **Fragment removal**
* `example.com/page#section` → `example.com/page`
---
## 14. Tracking Parameter Policy
### Pattern Matching
```haskell
matchesTrackingPattern :: Text -> Text -> Bool
matchesTrackingPattern pattern param
| "*" `T.isSuffixOf` pattern = T.init pattern `T.isPrefixOf` param
| otherwise = pattern == param
```
### Configurable Allowlist
Some parameters look like tracking but are semantically meaningful:
```haskell
-- Example: preserve 'ref' for Amazon affiliate links in some contexts
config :: NormConfig
config = defaultConfig
& #tracking . #allowlist .~ Set.fromList ["ref"]
```
### Update Strategy
The tracking parameter list is:
1. Hardcoded defaults in the library
2. Overridable via configuration
3. Documented in a central location
4. Versioned with semantic versioning (additions are minor, removals are major)
---
## 15. Domain-Specific Normalization
### Domain Rule Model
```haskell
data DomainRule = DomainRule
{ name :: Text
, match :: URI -> Bool
, apply :: URI -> URI
}
deriving stock (Generic)
-- Convenience constructor
domainRule :: Text -> (URI -> Bool) -> (URI -> URI) -> DomainRule
domainRule n m a = DomainRule { name = n, match = m, apply = a }
-- Application: first match wins
applyDomainRules :: [DomainRule] -> URI -> URI
applyDomainRules rules uri =
case find (\r -> (r ^. #match) uri) rules of
Nothing -> uri
Just r -> (r ^. #apply) uri
```
### Rule Composition
Rules are **not** composed—first match wins. However, generic normalization runs both **before** and **after** domain rules:
```
Generic normalize → Domain rule → Generic normalize (tracking only)
```
This ensures domain rules can introduce URLs that still need tracking param removal.
### Required Documentation per Rule
Each domain rule module must document:
1. **Canonical form**: The target URL structure
2. **Handled variants**: What URL patterns are normalized
3. **Preserved parameters**: Semantically meaningful params kept
4. **Stripped parameters**: Params removed as noise
5. **Examples**: Before/after pairs
---
## 16. Built-in Domain Rules
### YouTube
**Canonical form:**
```
https://www.youtube.com/watch?v={VIDEO_ID}
```
**Handled variants:**
* `youtu.be/{VIDEO_ID}`
* `youtube.com/watch?v={VIDEO_ID}`
* `www.youtube.com/watch?v={VIDEO_ID}`
* `m.youtube.com/watch?v={VIDEO_ID}`
* `youtube.com/embed/{VIDEO_ID}`
* `youtube.com/v/{VIDEO_ID}`
* `youtube.com/shorts/{VIDEO_ID}`
**Stripped parameters:** `t`, `start`, `end`, `feature`, `list`, `index`, `si`
**Note:** Timestamps are stripped per our resource-identity semantic model.
---
### Amazon
**Canonical form:**
```
https://www.amazon.com/dp/{ASIN}
```
**Handled variants:**
* `amazon.com/dp/{ASIN}`
* `amazon.com/gp/product/{ASIN}`
* `amazon.com/{product-name}/dp/{ASIN}`
* `amzn.to/{SHORT_CODE}` (requires redirect resolution)
**Stripped parameters:** `tag`, `ref`, `psc`, `keywords`, `qid`, `sr`
**Regional handling:** Normalize to `.com` or preserve regional TLD (configurable).
---
### GitHub
**Canonical form:**
```
https://github.com/{owner}/{repo}[/path]
```
**Handled variants:**
* `www.github.com` → `github.com`
* Fragments preserved for file links: `#L123` (exception to fragment stripping)
**Stripped parameters:** `tab`, `ref_src`, `ref_source`
---
### Twitter / X
**Canonical form:**
```
https://x.com/{user}/status/{id}
```
**Handled variants:**
* `twitter.com` → `x.com`
* `mobile.twitter.com` → `x.com`
* `x.com/{user}/status/{id}/photo/1` → `x.com/{user}/status/{id}`
* `x.com/{user}/status/{id}/video/1` → `x.com/{user}/status/{id}`
**Stripped parameters:** `s`, `t`, `ref_src`
---
### Instagram
**Canonical form:**
```
https://www.instagram.com/p/{POST_ID}/
https://www.instagram.com/reel/{REEL_ID}/
https://www.instagram.com/{username}/
```
**Handled variants:**
* `instagram.com` → `www.instagram.com`
**Stripped parameters:** `igshid`, `utm_*`
---
### Reddit
**Canonical form:**
```
https://www.reddit.com/r/{subreddit}/comments/{id}/{slug}/
```
**Handled variants:**
* `old.reddit.com` → `www.reddit.com`
* `np.reddit.com` → `www.reddit.com`
* `redd.it/{id}` (requires redirect resolution)
**Stripped parameters:** `utm_*`, `ref`, `ref_source`
---
### LinkedIn
**Canonical form:**
```
https://www.linkedin.com/posts/{id}
https://www.linkedin.com/in/{username}/
```
**Stripped parameters:** `miniProfileUrn`, `lipi`, `lici`
---
### URL Shorteners
These require redirect resolution and produce no direct canonical form:
* `bit.ly`
* `t.co`
* `goo.gl`
* `tinyurl.com`
* `ow.ly`
* `is.gd`
* `buff.ly`
After redirect resolution, the target URL is then normalized.
---
## 17. Rule Ordering Strategy
```haskell
defaultDomainRules :: [DomainRule]
defaultDomainRules =
[ youtubeRule -- High volume, complex variants
, amazonRule -- High volume, complex paths
, twitterRule -- Domain migration (twitter→x)
, instagramRule
, redditRule
, linkedInRule
, githubRule
]
```
Order rationale:
1. **Most specific first**: Complex extraction rules before simple ones
2. **Highest volume**: Optimize for common cases
3. **Recently changed**: Platforms with recent migrations (Twitter→X)
---
## 18. Configuration-Driven Rules
### Declarative Rule Format
```haskell
data DeclRule = DeclRule
{ domains :: [Text] -- e.g., ["youtube.com", "youtu.be"]
, canonicalHost :: Maybe Text -- e.g., Just "www.youtube.com"
, keepParams :: [Text] -- e.g., ["v"]
, stripParams :: [Text] -- e.g., ["t", "feature"]
, pathPatterns :: [PathPattern] -- Pattern matching for paths
, stripFragment :: Bool
}
deriving stock (Generic)
data PathPattern = PathPattern
{ match :: Text -- e.g., "/embed/{id}"
, rewrite :: Text -- e.g., "/watch?v={id}"
}
deriving stock (Generic, Eq, Show)
```
### Dhall Configuration Example
```dhall
let Rule = { domains : List Text
, canonicalHost : Optional Text
, keepParams : List Text
, stripParams : List Text
, pathPatterns : List { match : Text, rewrite : Text }
, stripFragment : Bool
}
let youtube : Rule =
{ domains = ["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"]
, canonicalHost = Some "www.youtube.com"
, keepParams = ["v"]
, stripParams = ["t", "feature", "list", "index", "si"]
, pathPatterns =
[ { match = "/embed/{id}", rewrite = "/watch?v={id}" }
, { match = "/v/{id}", rewrite = "/watch?v={id}" }
, { match = "/shorts/{id}", rewrite = "/watch?v={id}" }
]
, stripFragment = True
}
in [youtube]
```
### Runtime Interpretation
```haskell
interpretRule :: DeclRule -> DomainRule
loadRulesFromDhall :: FilePath -> IO [DomainRule]
```
---
## 19. Result Types
### Rich Result
```haskell
data NormResult = NormResult
{ canonical :: URI
, original :: URI
, redirectsCount :: Int
, redirectChain :: [URI] -- For debugging
, ruleApplied :: Maybe Text -- Name of domain rule, if any
, paramsStripped :: [Text] -- Tracking params removed
}
deriving stock (Generic, Eq, Show)
```
### Accessing Results with Lenses
```haskell
processResult :: NormResult -> IO ()
processResult result = do
print $ result ^. #canonical
when (result ^. #redirectsCount > 5) $
putStrLn "Warning: long redirect chain"
forM_ (result ^. #ruleApplied) $ \rule ->
putStrLn $ "Applied rule: " <> unpack rule
```
### Observability Trace
```haskell
data NormTrace = NormTrace
{ input :: URI
, afterRedirects :: URI
, afterGeneric :: URI
, afterDomain :: URI
, final :: URI
, redirectChain :: [URI]
, ruleApplied :: Maybe Text
, duration :: NominalDiffTime
}
deriving stock (Generic, Eq, Show)
```
---
## 20. Observability Hooks
```haskell
data NormHooks m = NormHooks
{ onRedirectStep :: URI -> URI -> m ()
, onRuleApplied :: Text -> URI -> URI -> m ()
, onNormComplete :: NormTrace -> m ()
}
deriving stock (Generic)
defaultHooks :: Applicative m => NormHooks m
defaultHooks = NormHooks
{ onRedirectStep = \_ _ -> pure ()
, onRuleApplied = \_ _ _ -> pure ()
, onNormComplete = \_ -> pure ()
}
```
---
## 21. Security Considerations
### SSRF Prevention
* **Private IP blocking**: Enabled by default
* **Scheme restriction**: Only `http`/`https`
* **No file:// or other schemes**
### Downgrade Attack Prevention
* **https→http rejected by default**
* Configurable for legacy systems
### Resource Exhaustion Prevention
* **Max redirects**: Default 10
* **Timeout**: Default 10 seconds per request
* **Loop detection**: Set-based visited tracking
### Input Validation
* Invalid URIs rejected early with `ParseError`
* Malformed percent-encoding handled gracefully
---
## 22. Testing Strategy
### Property Tests
```haskell
-- Idempotence
prop_idempotent :: URI -> Property
prop_idempotent uri =
normalizeUri config uri === normalizeUri config (normalizeUri config uri)
-- Determinism
prop_deterministic :: URI -> Property
prop_deterministic uri =
normalizeUri config uri === normalizeUri config uri
-- Query param order independence
prop_paramOrderIndependent :: URI -> [(Text, Text)] -> Property
prop_paramOrderIndependent uri params =
let uri1 = setParams params uri
uri2 = setParams (reverse params) uri
in normalizeUri config uri1 === normalizeUri config uri2
```
### Golden Tests
Each domain rule has a fixture file:
```yaml
# test/fixtures/youtube.yaml
- input: "https://youtu.be/dQw4w9WgXcQ"
expected: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
- input: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30&feature=share"
expected: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
- input: "https://youtube.com/embed/dQw4w9WgXcQ"
expected: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
```
### Effectful Tests
```haskell
-- Stubbed HttpClient for deterministic testing
newtype MockHttp a = MockHttp (Reader RedirectMap a)
deriving stock (Generic)
instance HttpClient MockHttp where
headRequest uri = MockHttp $ asks (lookup uri)
-- Test redirect chains
test_redirectChain :: TestTree
test_redirectChain = testCase "follows redirect chain" $ do
let redirects = Map.fromList
[ (uri "http://a.com", redirect "http://b.com")
, (uri "http://b.com", redirect "http://c.com")
, (uri "http://c.com", success)
]
result <- runMockHttp redirects $ resolveFinalUri config (uri "http://a.com")
result @?= Right (uri "http://c.com")
-- Test loop detection
test_loopDetection :: TestTree
test_loopDetection = testCase "detects redirect loop" $ do
let redirects = Map.fromList
[ (uri "http://a.com", redirect "http://b.com")
, (uri "http://b.com", redirect "http://a.com")
]
result <- runMockHttp redirects $ resolveFinalUri config (uri "http://a.com")
assertBool "should detect loop" (isRedirectLoop result)
```
### Integration Tests (Optional)
Live tests against real URLs, run manually or in CI with rate limiting:
```haskell
-- Only run with --integration flag
test_liveYouTube :: TestTree
test_liveYouTube = testCase "live YouTube normalization" $ do
result <- normalizeWithDefaults (uri "https://youtu.be/dQw4w9WgXcQ")
fmap (^. #canonical) result @?= Right (uri "https://www.youtube.com/watch?v=dQw4w9WgXcQ")
```
---
## 23. Performance Considerations
### Connection Pooling
```haskell
-- Reuse Manager across calls
withHttpClient :: (HttpClient IO -> IO a) -> IO a
withHttpClient action = do
manager <- newTlsManager
action (httpClientFromManager manager)
```
### Caching
Optional redirect cache for repeated normalization:
```haskell
data CacheConfig = CacheConfig
{ maxSize :: Int
, ttl :: NominalDiffTime
}
deriving stock (Generic)
newtype CachedHttpClient m a = CachedHttpClient
{ runCached :: StateT (Map URI (URI, UTCTime)) m a }
deriving stock (Generic)
```
### Batch Processing
```haskell
normalizeLinks
:: (MonadIO m, MonadCatch m)
=> NormConfig
-> HttpClient m
-> [URI]
-> m [Either NormError NormResult]
```
With configurable concurrency limits.
---
## 24. Versioning Strategy
### Semantic Versioning
* **Major**: Breaking changes to API, removal of domain rules, removal of tracking params
* **Minor**: New domain rules, new tracking params, new config options
* **Patch**: Bug fixes, documentation
### Domain Rule Versioning
Rules are versioned independently:
```haskell
youtubeRule :: DomainRule
youtubeRule = DomainRule
{ name = "youtube-v2"
, match = matchYouTube
, apply = applyYouTube
}
```
Breaking changes to a rule bump its internal version.
### Deprecation Policy
* Deprecated rules/params remain for 2 major versions
* Deprecation warnings via compile-time or runtime logging
---
## 25. Module Structure
```
link-canonical/
├── src/
│ └── Link/
│ ├── Canonical.hs -- Main entry point, re-exports
│ └── Canonical/
│ ├── Prelude.hs -- Package prelude with lens re-exports
│ ├── Types.hs -- Core types (NormConfig, NormResult, etc.)
│ ├── Config.hs -- Configuration defaults and builders
│ ├── Error.hs -- Error types
│ ├── Normalize.hs -- Generic normalization
│ ├── Redirect.hs -- Redirect resolution
│ ├── Http.hs -- HttpClient abstraction
│ ├── Tracking.hs -- Tracking parameter handling
│ └── Rules/
│ ├── Types.hs -- DomainRule type
│ ├── YouTube.hs
│ ├── Amazon.hs
│ ├── Twitter.hs
│ ├── GitHub.hs
│ ├── Instagram.hs
│ ├── Reddit.hs
│ ├── LinkedIn.hs
│ └── Default.hs -- Default rule set
├── test/
│ ├── Main.hs
│ ├── Link/
│ │ └── Canonical/
│ │ ├── NormalizeSpec.hs
│ │ ├── RedirectSpec.hs
│ │ └── Rules/
│ │ ├── YouTubeSpec.hs
│ │ └── ...
│ └── fixtures/
│ ├── youtube.yaml
│ └── ...
└── link-canonical.cabal
```
### Cabal Configuration
```cabal
cabal-version: 3.0
name: link-canonical
version: 0.1.0.0
common common
ghc-options: -Wall -Wunused-packages
default-language: GHC2024
default-extensions:
DeriveAnyClass
DuplicateRecordFields
NoFieldSelectors
OverloadedLabels
OverloadedStrings
StrictData
library
import: common
hs-source-dirs: src
exposed-modules:
Link.Canonical
Link.Canonical.Types
Link.Canonical.Config
Link.Canonical.Rules.YouTube
-- etc.
other-modules:
Link.Canonical.Prelude
Link.Canonical.Normalize
Link.Canonical.Redirect
Link.Canonical.Http
Link.Canonical.Tracking
Link.Canonical.Rules.Types
-- etc.
build-depends:
, base >=4.18 && <5
, bytestring
, containers
, generic-lens >=2.2
, http-client
, http-client-tls
, http-types
, lens >=5.2
, modern-uri >=0.3
, mtl
, text
, time
test-suite link-canonical-test
import: common
type: exitcode-stdio-1.0
hs-source-dirs: test
main-is: Main.hs
build-depends:
, base
, hedgehog
, link-canonical
, tasty
, tasty-hedgehog
, tasty-hunit
```
---
## 26. Future Considerations
### Potential Additions
* **Google AMP unwrapping**: `google.com/amp/...` → original URL
* **Archive.org handling**: Extract original URL from Wayback Machine links
* **Medium**: Normalize custom domains to medium.com
* **TikTok**: Complex mobile/web variants
* **Hacker News**: Handle ycombinator short links
### Out of Scope (Revisit Later)
* Machine learning for unknown tracking parameters
* Headless browser for JavaScript redirects
* Rate limiting / politeness policies
---
## 27. Summary
This library treats URLs as *semantic identifiers*, not strings.
Key design decisions:
| Decision | Rationale |
|----------|-----------|
| `modern-uri` | Best ergonomics for Text-based manipulation |
| HEAD requests | Efficient redirect resolution |
| First-match rules | Simple, predictable semantics |
| Two-pass generic normalization | Domain rules can introduce trackable params |
| Resource identity semantics | Clear definition of "canonical" |
| Layered API | Flexibility for different use cases |
| Security by default | SSRF prevention, scheme restrictions |
| `DuplicateRecordFields` + `OverloadedLabels` | Clean field names, ergonomic access |
| `generic-lens` | Uniform field access via `#fieldName` |
| Package prelude | Reduced boilerplate, consistent imports |
The library provides:
* Stable link identity
* Deduplication support
* Cache correctness
* Event correlation capabilities
In event-driven systems, canonical URLs function as **entity identifiers**.