Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import scala.language.implicitConversions

import zio.schema._

import zio.http.datastar.{EndpointRequest => ER}
import zio.http.template2.Dom.AttributeValue
import zio.http.template2._

Expand Down Expand Up @@ -217,6 +218,11 @@ trait Attributes {
*/
final def dataText: DatastarAttribute = DatastarAttribute(s"$prefix-text")

/**
* Helper for creating Datastar fetch actions from endpoint definitions.
*/
final def dataFetch: PartialDataFetch = PartialDataFetch(prefix)

}

object Attributes {
Expand Down Expand Up @@ -795,4 +801,35 @@ object Attributes {
case object Snake extends CaseModifier
case object Pascal extends CaseModifier
}

/**
* Helper for creating Datastar fetch actions.
*/
final case class PartialDataFetch(prefix: String) {

/**
* Creates a GET request action.
*/
def get(url: String): ER = ER.get(url)

/**
* Creates a POST request action.
*/
def post(url: String): ER = ER.post(url)

/**
* Creates a PUT request action.
*/
def put(url: String): ER = ER.put(url)

/**
* Creates a PATCH request action.
*/
def patch(url: String): ER = ER.patch(url)

/**
* Creates a DELETE request action.
*/
def delete(url: String): ER = ER.delete(url)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ trait DatastarPackageBase extends Attributes {
)
}

implicit class EndpointRequestExtensions[PathInput, Input, Err, Output, Auth <: AuthType](
endpoint: Endpoint[PathInput, Input, Err, Output, Auth],
) {

/**
* Creates a builder for Datastar request expressions from this endpoint.
*/
def toDatastarRequest
: zio.http.datastar.EndpointRequest.EndpointRequestBuilder[PathInput, Input, Err, Output, Auth] =
zio.http.datastar.EndpointRequest.fromEndpoint(endpoint)
}

implicit def signalUpdateToModifier[A](signalUpdate: SignalUpdate[A]): Modifier =
dataSignals(signalUpdate.signal)(signalUpdate.signal.schema) := signalUpdate.toExpression

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
package zio.http.datastar

import scala.language.implicitConversions

import zio.Chunk

import zio.http._
import zio.http.codec._
import zio.http.endpoint.{AuthType, Endpoint}
import zio.http.template2._

/**
* Represents a Datastar request action that can be used with data-on-*
* attributes to make HTTP requests to endpoints.
*/
sealed trait EndpointRequest {
def method: Method
def url: String
def headers: Map[String, String]
def includeHeaders: Boolean
def onlyIfMissing: Boolean

/**
* Returns the Datastar action expression for this request. Example: @get('/api/users')
*/
def toActionExpression: Js

/**
* Returns the full Datastar fetch expression with all options. Example: @get('/api/users',
* headers: {})
*/
def toFetchExpression: Js = {
val parts = scala.collection.mutable.ListBuffer[String]()

if (headers.nonEmpty) {
val headerStr = headers.map { case (k, v) => s"'$k': '$v'" }.mkString("{", ", ", "}")
parts += s"headers: $headerStr"
}

if (includeHeaders) {
parts += "includeHeaders: true"
}

if (onlyIfMissing) {
parts += "onlyIfMissing: true"
}

val options = if (parts.nonEmpty) s", ${parts.mkString(", ")}" else ""
js"@${method.toString().toLowerCase}('$url'$options)"
}

/**
* Sets whether to include request headers from the current page.
*/
def withIncludeHeaders(include: Boolean = true): EndpointRequest

/**
* Sets whether to only fetch if the signal is missing.
*/
def withOnlyIfMissing(only: Boolean = true): EndpointRequest

/**
* Adds a custom header to the request.
*/
def withHeader(key: String, value: String): EndpointRequest

/**
* Adds multiple custom headers to the request.
*/
def withHeaders(newHeaders: Map[String, String]): EndpointRequest
}

object EndpointRequest {

private final case class EndpointRequestImpl(
method: Method,
url: String,
headers: Map[String, String] = Map.empty,
includeHeaders: Boolean = false,
onlyIfMissing: Boolean = false,
) extends EndpointRequest {

override def toActionExpression: Js = js"@${method.toString().toLowerCase}('$url')"

override def withIncludeHeaders(include: Boolean): EndpointRequest =
copy(includeHeaders = include)

override def withOnlyIfMissing(only: Boolean): EndpointRequest =
copy(onlyIfMissing = only)

override def withHeader(key: String, value: String): EndpointRequest =
copy(headers = headers + (key -> value))

override def withHeaders(newHeaders: Map[String, String]): EndpointRequest =
copy(headers = headers ++ newHeaders)
}

/**
* Creates a Datastar request action from an endpoint definition.
*/
def fromEndpoint[PathInput, Input, Err, Output, Auth <: AuthType](
endpoint: Endpoint[PathInput, Input, Err, Output, Auth],
): EndpointRequestBuilder[PathInput, Input, Err, Output, Auth] =
EndpointRequestBuilder(endpoint)

/**
* Creates a GET request to the specified URL.
*/
def get(url: String): EndpointRequest =
EndpointRequestImpl(Method.GET, url)

/**
* Creates a POST request to the specified URL.
*/
def post(url: String): EndpointRequest =
EndpointRequestImpl(Method.POST, url)

/**
* Creates a PUT request to the specified URL.
*/
def put(url: String): EndpointRequest =
EndpointRequestImpl(Method.PUT, url)

/**
* Creates a PATCH request to the specified URL.
*/
def patch(url: String): EndpointRequest =
EndpointRequestImpl(Method.PATCH, url)

/**
* Creates a DELETE request to the specified URL.
*/
def delete(url: String): EndpointRequest =
EndpointRequestImpl(Method.DELETE, url)

/**
* Builder for creating endpoint requests with parameter substitution.
*/
final case class EndpointRequestBuilder[PathInput, Input, Err, Output, Auth <: AuthType](
endpoint: Endpoint[PathInput, Input, Err, Output, Auth],
headers: Map[String, String] = Map.empty,
includeHeaders: Boolean = false,
onlyIfMissing: Boolean = false,
) {

/**
* Builds the request with path and query parameters substituted. Path
* parameters are extracted from the endpoint's route pattern.
*/
def build(pathParams: PathInput = null.asInstanceOf[PathInput]): EndpointRequest = {
val method = extractMethod(endpoint.route)
val path = buildPath(endpoint.route, pathParams)

EndpointRequestImpl(
method = method,
url = path,
headers = headers,
includeHeaders = includeHeaders,
onlyIfMissing = onlyIfMissing,
)
}

/**
* Builds the request with path parameters as signals (for dynamic
* substitution).
*/
def buildWithSignals(pathParams: String => String = identity): EndpointRequest = {
val method = extractMethod(endpoint.route)
val path = buildPathWithSignals(endpoint.route, pathParams)

EndpointRequestImpl(
method = method,
url = path,
headers = headers,
includeHeaders = includeHeaders,
onlyIfMissing = onlyIfMissing,
)
}

/**
* Sets whether to include request headers from the current page.
*/
def withIncludeHeaders(include: Boolean = true): EndpointRequestBuilder[PathInput, Input, Err, Output, Auth] =
copy(includeHeaders = include)

/**
* Sets whether to only fetch if the signal is missing.
*/
def withOnlyIfMissing(only: Boolean = true): EndpointRequestBuilder[PathInput, Input, Err, Output, Auth] =
copy(onlyIfMissing = only)

/**
* Adds a custom header to the request.
*/
def withHeader(key: String, value: String): EndpointRequestBuilder[PathInput, Input, Err, Output, Auth] =
copy(headers = headers + (key -> value))

/**
* Adds multiple custom headers to the request.
*/
def withHeaders(newHeaders: Map[String, String]): EndpointRequestBuilder[PathInput, Input, Err, Output, Auth] =
copy(headers = headers ++ newHeaders)

private def extractMethod(route: RoutePattern[_]): Method = {
route.method
}

private def buildPath(route: RoutePattern[_], pathParams: PathInput): String = {
if (pathParams == null) {
// Build path without parameter substitution (will use placeholder format)
"/" + route.pathCodec.segments.map { segment =>
val segmentStr = segment.toString
if (segmentStr.startsWith("Literal(")) {
extractLiteralValue(segmentStr)
} else {
s"{${extractSegmentName(segmentStr)}}"
}
}
.mkString("/")
} else {
// Build path with actual parameter values
val formatted = endpoint.route.asInstanceOf[RoutePattern[PathInput]].format(pathParams)
formatted match {
case Right(path) => "/" + path.encode.dropWhile(_ == '/')
case Left(_) => "/" // fallback to root if formatting fails
}
}
}

private def buildPathWithSignals(route: RoutePattern[_], signalMapper: String => String): String = {
"/" + route.pathCodec.segments.map { segment =>
val segmentStr = segment.toString
if (segmentStr.startsWith("Literal(")) {
extractLiteralValue(segmentStr)
} else {
val name = extractSegmentName(segmentStr)
s"$${${signalMapper(name)}}"
}
}
.mkString("/")
}

private def extractLiteralValue(literalStr: String): String = {
// Extract the string value from Literal(...)
literalStr.stripPrefix("Literal(").stripSuffix(")").trim
}

private def extractSegmentName(segmentStr: String): String = {
// Extract parameter name from segment description
// This is a simplified approach - may need adjustment based on actual PathCodec structure
val name = if (segmentStr.contains("(")) {
segmentStr.substring(segmentStr.indexOf("(") + 1, segmentStr.lastIndexOf(")"))
} else {
segmentStr
}
// Remove quotes if present
name.replaceAll("\"", "").replaceAll("'", "")
}
}

implicit def endpointRequestToJs(request: EndpointRequest): Js =
request.toActionExpression
}
Loading
Loading