Lift / http4s thoughts

This commit is contained in:
simonredfern 2025-12-03 08:09:36 +01:00
parent b461724299
commit 9260ac4e0a

650
LIFT_HTTP4S_COEXISTENCE.md Normal file
View File

@ -0,0 +1,650 @@
# Lift and http4s Coexistence Strategy
## Question
Can http4s and Lift coexist in the same project to convert endpoints one by one?
## Answer: Yes, on Different Ports
### Architecture Overview
```
┌─────────────────────────────────────────┐
│ OBP-API Application │
├─────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌─────────────────┐ │
│ │ Lift/Jetty │ │ http4s Server │ │
│ │ Port 8080 │ │ Port 8081 │ │
│ └──────────────┘ └─────────────────┘ │
│ │ │ │
│ └──────┬───────────┘ │
│ │ │
│ Shared Resources: │
│ - Database │
│ - Business Logic │
│ - Authentication │
│ - ResourceDocs │
└─────────────────────────────────────────┘
```
### How It Works
1. **Two HTTP Servers Running Simultaneously**
- Lift/Jetty continues on port 8080
- http4s starts on port 8081
- Both run in the same JVM process
2. **Shared Components**
- Database connections
- Business logic layer
- Authentication/authorization
- Configuration
- Connector layer
3. **Gradual Migration**
- Start: All endpoints on Lift (port 8080)
- During: Some on Lift, some on http4s
- End: All on http4s (port 8081), Lift removed
## Implementation Approach
### Step 1: Add http4s to Project
```scala
// build.sbt
libraryDependencies ++= Seq(
"org.http4s" %% "http4s-dsl" % "0.23.x",
"org.http4s" %% "http4s-ember-server" % "0.23.x",
"org.http4s" %% "http4s-ember-client" % "0.23.x",
"org.http4s" %% "http4s-circe" % "0.23.x"
)
```
### Step 2: Create http4s Server
```scala
// code/api/http4s/Http4sServer.scala
package code.api.http4s
import cats.effect._
import org.http4s.ember.server.EmberServerBuilder
import com.comcast.ip4s._
object Http4sServer {
def start(port: Int = 8081): IO[Unit] = {
EmberServerBuilder
.default[IO]
.withHost(ipv4"0.0.0.0")
.withPort(Port.fromInt(port).get)
.withHttpApp(routes.orNotFound)
.build
.useForever
}
def routes = ??? // Define routes here
}
```
### Step 3: THE JETTY PROBLEM
**IMPORTANT:** If you start http4s from Lift's Bootstrap, it runs INSIDE Jetty's servlet container. This defeats the purpose of using http4s!
```
❌ WRONG APPROACH:
┌─────────────────────────────┐
│ Jetty Servlet Container │
│ ├─ Lift (port 8080) │
│ └─ http4s (port 8081) │ ← Still requires Jetty!
└─────────────────────────────┘
```
**The Problem:**
- http4s would still require Jetty to run
- Can't remove servlet container later
- Defeats the goal of eliminating Jetty
### Step 3: CORRECT APPROACH - Separate Main
**Solution:** Start http4s as a STANDALONE server, NOT from Lift Bootstrap.
```
✓ CORRECT APPROACH:
┌──────────────────┐ ┌─────────────────┐
│ Jetty Container │ │ http4s Server │
│ └─ Lift │ │ (standalone) │
│ Port 8080 │ │ Port 8081 │
└──────────────────┘ └─────────────────┘
Same JVM Process
```
#### Option A: Two Separate Processes (Simpler)
```scala
// Run Lift as usual
sbt "jetty:start" // Port 8080
// Run http4s separately
sbt "runMain code.api.http4s.Http4sMain" // Port 8081
```
**Deployment:**
```bash
# Start Lift/Jetty
java -jar obp-api-jetty.jar &
# Start http4s standalone
java -jar obp-api-http4s.jar &
```
**Pros:**
- Complete separation
- Easy to understand
- Can stop/restart independently
- No Jetty dependency for http4s
**Cons:**
- Two separate processes to manage
- Two JVMs (more memory)
#### Option B: Single JVM with Two Threads (Complex but Doable)
```scala
// Main.scala - Entry point
object Main extends IOApp {
def run(args: List[String]): IO[ExitCode] = {
for {
// Start Lift/Jetty in background fiber
liftFiber <- startLiftServer().start
// Start http4s server (blocks main thread)
_ <- Http4sServer.start(8081)
} yield ExitCode.Success
}
def startLiftServer(): IO[Unit] = IO {
// Start Jetty programmatically
val server = new Server(8080)
val context = new WebAppContext()
context.setContextPath("/")
context.setWar("src/main/webapp")
server.setHandler(context)
server.start()
// Don't call server.join() - let it run in background
}
}
```
**Pros:**
- Single process
- Shared JVM, less memory
- Shared resources easier
**Cons:**
- More complex startup
- Harder to debug
- Mixed responsibilities
#### Option C: Use Jetty for Both (Transition Strategy)
During migration, you CAN start http4s from Lift Bootstrap using a servlet adapter, but this is TEMPORARY:
```scala
// bootstrap/liftweb/Boot.scala
class Boot {
def boot {
// Existing Lift setup
LiftRules.addToPackages("code")
// Add http4s routes to Jetty (TEMPORARY)
if (APIUtil.getPropsAsBoolValue("http4s.enabled", false)) {
// Add http4s servlet on different context path
val http4sServlet = new Http4sServlet[IO](http4sRoutes)
LiftRules.context.addServlet(http4sServlet, "/http4s/*")
}
}
}
```
Access via:
- Lift: `http://localhost:8080/obp/v6.0.0/banks/...`
- http4s: `http://localhost:8080/http4s/obp/v6.0.0/banks/...`
**Pros:**
- Single port
- Easy during development
**Cons:**
- http4s still requires Jetty
- Can't remove servlet container
- Only for development/testing
### RECOMMENDED APPROACH
**For actual migration, use Option A (Two Separate Processes):**
1. **Keep Lift/Jetty running as-is** on port 8080
2. **Create standalone http4s server** on port 8081 with its own Main class
3. **Use reverse proxy** (nginx/HAProxy) to route requests
4. **Migrate endpoints one by one** to http4s
5. **Eventually remove Lift/Jetty** completely
```
Phase 1-3 (Migration):
┌─────────────┐
│ Nginx │ Port 443
│ (Proxy) │
└──────┬──────┘
├──→ Jetty/Lift (Process 1) Port 8080 ← Old endpoints
└──→ http4s standalone (Process 2) Port 8081 ← New endpoints
Phase 4 (Complete):
┌─────────────┐
│ Nginx │ Port 443
│ (Proxy) │
└──────┬──────┘
└──→ http4s standalone Port 8080 ← All endpoints
(Jetty removed!)
```
**This way http4s is NEVER dependent on Jetty.**
### Step 4: Shared Business Logic
```scala
// Keep business logic separate from HTTP layer
package code.api.service
object UserService {
// Pure business logic - no Lift or http4s dependencies
def createUser(username: String, email: String, password: String): Box[User] = {
// Implementation
}
}
// Use in Lift endpoint
class LiftEndpoints extends RestHelper {
serve("obp" / "v6.0.0" prefix) {
case "users" :: Nil JsonPost json -> _ => {
val result = UserService.createUser(...)
// Return Lift response
}
}
}
// Use in http4s endpoint
class Http4sEndpoints[F[_]: Concurrent] {
def routes: HttpRoutes[F] = HttpRoutes.of[F] {
case req @ POST -> Root / "obp" / "v6.0.0" / "users" =>
val result = UserService.createUser(...)
// Return http4s response
}
}
```
## Migration Strategy
### Phase 1: Setup (Week 1-2)
- Add http4s dependencies
- Create http4s server infrastructure
- Start http4s on port 8081
- Keep all endpoints on Lift
### Phase 2: Convert New Endpoints (Week 3-8)
- All NEW endpoints go to http4s only
- Existing endpoints stay on Lift
- Share business logic between both
### Phase 3: Migrate Existing Endpoints (Month 3-6)
Priority order:
1. Simple GET endpoints (read-only, no sessions)
2. POST endpoints with simple authentication
3. Endpoints with complex authorization
4. Admin/management endpoints
5. OAuth/authentication endpoints (last)
### Phase 4: Deprecation (Month 7-9)
- Announce Lift endpoints deprecated
- Run both servers (port 8080 and 8081)
- Redirect/proxy 8080 -> 8081
- Update documentation
### Phase 5: Removal (Month 10-12)
- Remove Lift dependencies
- Remove Jetty dependency
- Single http4s server on port 8080
- No servlet container needed
## Request Routing During Migration
### Option A: Two Separate Ports
```
Clients → Load Balancer
├─→ Port 8080 (Lift) - Old endpoints
└─→ Port 8081 (http4s) - New endpoints
```
**Pros:**
- Simple, clear separation
- Easy to monitor which endpoints are migrated
- No risk of conflicts
**Cons:**
- Clients need to know which port to use
- Load balancer configuration needed
### Option B: Proxy Pattern
```
Clients → Port 8080 (Lift)
├─→ Handle locally (Lift endpoints)
└─→ Proxy to 8081 (http4s endpoints)
```
**Pros:**
- Single port for clients
- Transparent migration
- No client changes needed
**Cons:**
- Additional latency for proxied requests
- More complex routing logic
### Option C: Reverse Proxy (Nginx/HAProxy)
```
Clients → Nginx (Port 443)
├─→ Port 8080 (Lift) - /api/v4.0.0/*
└─→ Port 8081 (http4s) - /api/v6.0.0/*
```
**Pros:**
- Professional solution
- Fine-grained routing rules
- SSL termination
- Load balancing
**Cons:**
- Additional infrastructure component
- Configuration overhead
## Database Access Migration
### Current: Lift Mapper
```scala
class AuthUser extends MegaProtoUser[AuthUser] {
// Mapper ORM
}
```
### During Migration: Keep Mapper
```scala
// Both Lift and http4s use Mapper
// No need to migrate DB layer immediately
import code.model.dataAccess.AuthUser
// In http4s endpoint
def getUser(id: String): IO[Option[User]] = IO {
AuthUser.find(By(AuthUser.id, id)).map(_.toUser)
}
```
### Future: Replace Mapper (Optional)
```scala
// Use Doobie or Skunk
import doobie._
import doobie.implicits._
def getUser(id: String): ConnectionIO[Option[User]] = {
sql"SELECT * FROM authuser WHERE id = $id"
.query[User]
.option
}
```
## Configuration
```properties
# props/default.props
# Enable http4s server
http4s.enabled=true
http4s.port=8081
# Lift/Jetty (keep running)
jetty.port=8080
# Migration mode
# - "dual" = both servers running
# - "http4s-only" = only http4s
migration.mode=dual
```
## Testing Strategy
### Test Both Implementations
```scala
class UserEndpointTest extends ServerSetup {
// Test Lift version
scenario("Create user via Lift (port 8080)") {
val request = (v6_0_0_Request / "users").POST
val response = makePostRequest(request, userJson)
response.code should equal(201)
}
// Test http4s version
scenario("Create user via http4s (port 8081)") {
val request = (http4s_v6_0_0_Request / "users").POST
val response = makePostRequest(request, userJson)
response.code should equal(201)
}
// Test both give same result
scenario("Both implementations return same result") {
val liftResult = makeLiftRequest(...)
val http4sResult = makeHttp4sRequest(...)
liftResult should equal(http4sResult)
}
}
```
## Resource Docs Compatibility
### Keep Same ResourceDoc Structure
```scala
// Shared ResourceDoc definition
val createUserDoc = ResourceDoc(
createUser,
implementedInApiVersion,
"createUser",
"POST",
"/users",
"Create User",
"""Creates a new user...""",
postUserJson,
userResponseJson,
List(UserNotLoggedIn, InvalidJsonFormat)
)
// Lift endpoint references it
lazy val createUserLift: OBPEndpoint = {
case "users" :: Nil JsonPost json -> _ => {
// implementation
}
}
// http4s endpoint references same doc
def createUserHttp4s[F[_]: Concurrent]: HttpRoutes[F] = {
case req @ POST -> Root / "users" => {
// implementation
}
}
```
## Advantages of Coexistence Approach
1. **Zero Downtime Migration**
- Old endpoints keep working
- New endpoints added incrementally
- No big-bang rewrite
2. **Risk Mitigation**
- Test new framework alongside old
- Easy rollback per endpoint
- Gradual learning curve
3. **Business Continuity**
- No disruption to users
- Features can still be added
- Migration in background
4. **Shared Resources**
- Same database
- Same business logic
- Same configuration
5. **Flexible Timeline**
- Migrate at your own pace
- Pause if needed
- No hard deadlines
## Challenges and Solutions
### Challenge 1: Port Management
**Solution:** Use property files to configure ports, allow override
### Challenge 2: Session/State Sharing
**Solution:** Use stateless JWT tokens, shared Redis for sessions
### Challenge 3: Authentication
**Solution:** Keep auth logic separate, callable from both frameworks
### Challenge 4: Database Connections
**Solution:** Shared connection pool, configure max connections appropriately
### Challenge 5: Monitoring
**Solution:** Separate metrics for each server, aggregate in monitoring system
### Challenge 6: Deployment
**Solution:** Single JAR with both servers, configure which to start
## Deployment Considerations
### Development
```bash
# Start with both servers
sbt run
# Lift on :8080, http4s on :8081
```
### Production - Transition Period
```
# Run both servers
java -Dhttp4s.enabled=true \
-Dhttp4s.port=8081 \
-Djetty.port=8080 \
-jar obp-api.jar
```
### Production - After Migration
```
# Only http4s
java -Dhttp4s.enabled=true \
-Dhttp4s.port=8080 \
-Dlift.enabled=false \
-jar obp-api.jar
```
## Example: First Endpoint Migration
### 1. Existing Lift Endpoint
```scala
// APIMethods600.scala
lazy val getBank: OBPEndpoint = {
case "banks" :: bankId :: Nil JsonGet _ => {
cc => implicit val ec = EndpointContext(Some(cc))
for {
bank <- Future { Connector.connector.vend.getBank(BankId(bankId)) }
} yield {
(bank, HttpCode.`200`(cc))
}
}
}
```
### 2. Create http4s Version
```scala
// code/api/http4s/endpoints/BankEndpoints.scala
class BankEndpoints[F[_]: Concurrent] {
def routes: HttpRoutes[F] = HttpRoutes.of[F] {
case GET -> Root / "obp" / "v6.0.0" / "banks" / bankId =>
// Same business logic
val bankBox = Connector.connector.vend.getBank(BankId(bankId))
bankBox match {
case Full(bank) => Ok(bank.toJson)
case Empty => NotFound()
case Failure(msg, _, _) => BadRequest(msg)
}
}
}
```
### 3. Both Available
- Lift: `http://localhost:8080/obp/v6.0.0/banks/{bankId}`
- http4s: `http://localhost:8081/obp/v6.0.0/banks/{bankId}`
### 4. Test Both
```scala
scenario("Get bank - Lift version") {
val response = makeGetRequest(v6_0_0_Request / "banks" / testBankId.value)
response.code should equal(200)
}
scenario("Get bank - http4s version") {
val response = makeGetRequest(http4s_v6_0_0_Request / "banks" / testBankId.value)
response.code should equal(200)
}
```
### 5. Deprecate Lift Version
- Add deprecation warning to Lift endpoint
- Update docs to point to http4s version
- Monitor usage
### 6. Remove Lift Version
- Delete Lift endpoint code
- All traffic to http4s
## Timeline Estimate
### Conservative Approach (12-18 months)
- **Month 1-2:** Setup and infrastructure
- **Month 3-6:** Migrate 25% of endpoints
- **Month 7-10:** Migrate 50% more (75% total)
- **Month 11-14:** Migrate remaining 25%
- **Month 15-16:** Testing and stabilization
- **Month 17-18:** Remove Lift, cleanup
### Aggressive Approach (6-9 months)
- **Month 1:** Setup
- **Month 2-5:** Migrate 80% of endpoints
- **Month 6-7:** Migrate remaining 20%
- **Month 8-9:** Remove Lift
## Conclusion
**Yes, Lift and http4s can coexist** by running on different ports (8080 and 8081) within the same application. This allows for:
- Gradual, low-risk migration
- Endpoint-by-endpoint conversion
- Shared business logic and resources
- Zero downtime
- Flexible timeline
The key is to **keep HTTP layer separate from business logic** so both frameworks can call the same underlying functions.
Start with simple read-only endpoints, then gradually migrate more complex ones, finally removing Lift when all endpoints are converted.