Skip to content

Commit

Permalink
fix(server): allow setting response headers
Browse files Browse the repository at this point in the history
The Netty response intrinsic provided to guests is missing a method
to set headers on the response. While we're there, I can add some
other methods users may need.

- fix: header methods on response intrinsic
- chore: re-pin `graalvm` module
- chore: update `graalvm` detekt baseline
- chore: more gitattribute fixes

Signed-off-by: Sam Gammon <sam@elide.ventures>
  • Loading branch information
sgammon committed Jun 6, 2024
1 parent 3b11b8e commit 20b7c5c
Show file tree
Hide file tree
Showing 7 changed files with 279 additions and 17 deletions.
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/*.js -linguist-detectable
/*.mjs -linguist-detectable
*.proto linguist-language=Protocol-Buffer linguist-detectable
*.soy linguist-language=Closure-Templates linguist-detectable
*.ts linguist-language=TypeScript linguist-detectable
Expand Down
15 changes: 14 additions & 1 deletion packages/graalvm/api/graalvm.api
Original file line number Diff line number Diff line change
Expand Up @@ -4196,6 +4196,17 @@ public final class elide/runtime/intrinsics/js/typed/UUID$UUIDType : java/lang/E
public static fun values ()[Lelide/runtime/intrinsics/js/typed/UUID$UUIDType;
}

public abstract interface class elide/runtime/intrinsics/server/http/ExpressResponseAPI {
public abstract fun append (Ljava/lang/String;Ljava/lang/String;)V
public static synthetic fun append$default (Lelide/runtime/intrinsics/server/http/ExpressResponseAPI;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V
public abstract fun end ()V
public abstract fun get (Ljava/lang/String;)Ljava/lang/String;
public abstract fun send (ILorg/graalvm/polyglot/Value;)V
public abstract fun set (Ljava/lang/String;Ljava/lang/String;)V
public static synthetic fun set$default (Lelide/runtime/intrinsics/server/http/ExpressResponseAPI;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V
public abstract fun status (I)V
}

public final class elide/runtime/intrinsics/server/http/HttpContext : java/util/Map, kotlin/jvm/internal/markers/KMutableMap, org/graalvm/polyglot/proxy/ProxyObject {
public fun clear ()V
public final fun containsKey (Ljava/lang/Object;)Z
Expand Down Expand Up @@ -4243,7 +4254,9 @@ public abstract interface class elide/runtime/intrinsics/server/http/HttpRequest
public abstract fun getUri ()Ljava/lang/String;
}

public abstract interface class elide/runtime/intrinsics/server/http/HttpResponse {
public abstract interface class elide/runtime/intrinsics/server/http/HttpResponse : elide/runtime/intrinsics/server/http/ExpressResponseAPI {
public fun append (Ljava/lang/String;Ljava/lang/String;)V
public abstract fun header (Ljava/lang/String;Ljava/lang/String;)V
public abstract fun send (ILorg/graalvm/polyglot/Value;)V
}

Expand Down
1 change: 1 addition & 0 deletions packages/graalvm/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<ID>ComplexCondition:NodeAssertTest.kt$NodeAssertTest$(left is Value &amp;&amp; left.isNull) || (right is Value &amp;&amp; right.isNull)</ID>
<ID>CyclomaticComplexMethod:ConsoleIntrinsic.kt$ConsoleIntrinsic$internal fun formatLogComponent(arg: Any?): Any</ID>
<ID>CyclomaticComplexMethod:CustomEvent.kt$CustomEvent$override fun getMember(key: String?): Any?</ID>
<ID>CyclomaticComplexMethod:NettyHttpResponse.kt$NettyHttpResponse$override fun getMember(key: String?): Any?</ID>
<ID>CyclomaticComplexMethod:NodeEvents.kt$NodeEventsModuleFacade$@Polyglot override fun getMember(key: String): Any?</ID>
<ID>CyclomaticComplexMethod:NodeOperatingSystem.kt$NodeOperatingSystem.ModuleBase$override fun getMember(key: String?): Any?</ID>
<ID>CyclomaticComplexMethod:NodePaths.kt$NodePaths.BasePaths$@Polyglot override fun format(pathObject: Any): String</ID>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2024 Elide Technologies, Inc.
*
* Licensed under the MIT license (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* https://opensource.org/license/mit/
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under the License.
*/
package elide.runtime.intrinsics.server.http

import elide.annotations.API
import elide.runtime.core.DelicateElideApi
import elide.runtime.core.PolyglotValue
import elide.vm.annotations.Polyglot

/**
* # Express Response API
*
* Defines methods available on an Express `Response` object; these are mixed into the standard [HttpResponse] so that
* guests may specify responses using this API.
*/
@API @DelicateElideApi public interface ExpressResponseAPI {
/**
* Exported method allowing guest code to set the response [status] code.
*
* This method is not terminal, unlike [send].
*
* @param status The HTTP status code to send.
*/
@Polyglot public fun status(status: Int)

/**
* Exported method allowing guest code to end the request/response cycle; equivalent to calling [send].
*
* This method is terminal.
*/
@Polyglot public fun end()

/**
* Exported method allowing guest code to send a response to the client with the given [status] code and [body].
*
* @param status The HTTP status code to send.
* @param body The body of the response to send.
*/
@Polyglot public fun send(status: Int, body: PolyglotValue?)

/**
* Get a header from the response; this method is exported to guest code.
*
* @param name The name of the header to set.
*/
@Polyglot public fun get(name: String): String?

/**
* Set a header to the response; this method is exported to guest code.
*
* Note that headers must be provided before [send] is called.
* This method will set a header to the response, overwriting any existing header(s) by the same name.
*
* @param name The name of the header to set.
* @param value The value of the header to set.
*/
@Polyglot public fun set(name: String, value: String? = null)

/**
* Append a header to the response; this method is exported to guest code.
*
* Note that headers must be provided before [send] is called.
* This method will add a header to the response even if there is already a header by the same name.
*
* @param name The name of the header to set.
* @param value The value of the header to set.
*/
@Polyglot public fun append(name: String, value: String? = null)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,43 @@
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under the License.
*/

package elide.runtime.intrinsics.server.http

import org.graalvm.polyglot.HostAccess.Export
import elide.annotations.API
import elide.runtime.core.DelicateElideApi
import elide.runtime.core.PolyglotValue
import elide.vm.annotations.Polyglot

/** Represents an HTTP response returned by the server, accessible from guest code. */
@DelicateElideApi public interface HttpResponse {
@API @DelicateElideApi public interface HttpResponse: ExpressResponseAPI {
/**
* Provide a header to the response; this method is exported to guest code.
*
* Note that headers must be provided before [send] is called.
*
* @param name The name of the header to set.
* @param value The value of the header to set.
*/
@Polyglot public fun header(name: String, value: String)

/**
* Exported method allowing guest code to send a response to the client with the given [status] code and [body].
*
* @param status The HTTP status code to send.
* @param body The body of the response to send.
*/
@Polyglot override fun send(status: Int, body: PolyglotValue?)

/**
* Append a header to the response; this method is exported to guest code.
*
* Note that headers must be provided before [send] is called.
* This method will add a header to the response even if there is already a header by the same name.
*
* @param name The name of the header to set.
* @param value The value of the header to set.
*/
@Export public fun send(status: Int, body: PolyglotValue?)
@Polyglot override fun append(name: String, value: String?) {
header(name, value ?: "")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,96 @@ package elide.runtime.intrinsics.server.http.netty
import io.netty.buffer.Unpooled
import io.netty.channel.ChannelHandlerContext
import io.netty.handler.codec.http.*
import org.graalvm.polyglot.HostAccess.Export
import org.graalvm.polyglot.Value
import org.graalvm.polyglot.proxy.ProxyExecutable
import org.graalvm.polyglot.proxy.ProxyObject
import java.text.DateFormat
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import elide.runtime.core.DelicateElideApi
import elide.runtime.core.PolyglotValue
import elide.runtime.gvm.internals.intrinsics.js.JsError
import elide.runtime.intrinsics.server.http.HttpRequest
import elide.runtime.intrinsics.server.http.HttpResponse
import elide.vm.annotations.Polyglot

// Methods and properties exposed to guest code on responses.
private val NETTY_HTTP_RESPONSE_PROPS_AND_METHODS = arrayOf(
"append",
"end",
"get",
"header",
"send",
"set",
"status",
)

/** [HttpRequest] implementation wrapping a Netty handler context. */
@DelicateElideApi internal class NettyHttpResponse(private val context: ChannelHandlerContext) : HttpResponse {
@DelicateElideApi internal class NettyHttpResponse(
private val context: ChannelHandlerContext,
private val includeDefaults: Boolean = true,
)
: HttpResponse, ProxyObject {
/** Whether the response has already been sent. */
private val sent = AtomicBoolean(false)

/** Whether the response has already been sent. */
private val body = AtomicReference<PolyglotValue>(null)

/** Explicit status set by guest code. */
private val status = AtomicInteger(0)

/** The HTTP version of the response. */
@Volatile private var httpVersion = HttpVersion.HTTP_1_1

/** Headers for this response, dispatched once the response is sent to the client. */
private val headers = DefaultHttpHeaders()

@Export override fun send(status: Int, body: PolyglotValue?) {
send(HttpResponseStatus.valueOf(status), body)
init {
// prepare headers
headers
.set(HttpHeaderNames.CONTENT_TYPE, HttpHeaderValues.TEXT_PLAIN)
.set(HttpHeaderNames.SERVER, "elide")
}

@Polyglot override fun header(name: String, value: String) {
headers.set(name, value)
}

@Polyglot override fun set(name: String, value: String?) {
headers.set(name, value)
}

@Polyglot override fun get(name: String): String? {
return headers.get(name)
}

@Polyglot override fun append(name: String, value: String?) {
headers.add(name, value)
}

@Polyglot override fun status(status: Int) {
this.status.set(status)
}

@Polyglot override fun send(status: Int, body: PolyglotValue?) {
status(status)
this.body.set(body)
end()
}

@Polyglot override fun end() {
val status = status.get()
val effective = if (status == 0) HttpResponseStatus.OK.code() else status
send(HttpResponseStatus.valueOf(effective), body.get())
}

// Fills response headers expected by-spec.
private fun fillResponseHeaders() {
if (includeDefaults) {
headers.set(HttpHeaderNames.DATE, DateFormat.getInstance().format(System.currentTimeMillis()))
}
}

/**
Expand All @@ -47,25 +120,22 @@ import elide.runtime.intrinsics.server.http.HttpResponse
// treat any type of value as a string (force conversion)
val content = body?.let { Unpooled.wrappedBuffer(it.toString().toByteArray()) } ?: Unpooled.EMPTY_BUFFER

// prepare headers
headers
.set(HttpHeaderNames.CONTENT_TYPE, HttpHeaderValues.TEXT_PLAIN)
.set(HttpHeaderNames.SERVER, "Elide")

// prepare the response object
fillResponseHeaders()

val response = if (content != null) {
headers.set(HttpHeaderNames.CONTENT_LENGTH, content.writerIndex())

DefaultFullHttpResponse(
/* version = */ HttpVersion.HTTP_1_1,
/* version = */ httpVersion,
/* status = */ status,
/* content = */ content,
/* headers = */ headers,
/* trailingHeaders = */ EmptyHttpHeaders.INSTANCE,
)
} else {
DefaultHttpResponse(
/* version = */ HttpVersion.HTTP_1_1,
/* version = */ httpVersion,
/* status = */ status,
/* headers = */ headers,
)
Expand All @@ -74,4 +144,75 @@ import elide.runtime.intrinsics.server.http.HttpResponse
// send the response
context.write(response)
}

override fun getMemberKeys(): Array<String> = NETTY_HTTP_RESPONSE_PROPS_AND_METHODS
override fun hasMember(key: String?): Boolean = key != null && key in NETTY_HTTP_RESPONSE_PROPS_AND_METHODS
override fun putMember(key: String?, value: Value?) {
// no-op
}

override fun removeMember(key: String?): Boolean {
return false // not supported
}

override fun getMember(key: String?): Any? = when (key) {
"get" -> ProxyExecutable {
val name = it.getOrNull(0)
when {
name == null || !name.isString -> throw JsError.valueError("Header name must be a string")
else -> get(name.asString())
}
}

"set" -> ProxyExecutable {
val name = it.getOrNull(0)
val value = it.getOrNull(1)
when {
name == null || !name.isString -> throw JsError.valueError("Header name must be a string")
value == null -> throw JsError.typeError("Header value is required")
else -> set(name.asString(), value.asString())
}
}

"append" -> ProxyExecutable {
val name = it.getOrNull(0)
val value = it.getOrNull(1)
when {
name == null || !name.isString -> throw JsError.valueError("Header name must be a string")
value == null -> throw JsError.typeError("Header value is required")
else -> append(name.asString(), value.asString())
}
}

"status" -> ProxyExecutable {
val status = it.getOrNull(0)
when {
status == null || !status.fitsInInt() -> throw JsError.typeError("Status code must be an integer")
else -> status(status.asInt())
}
}

"header" -> ProxyExecutable {
val name = it.getOrNull(0)
val value = it.getOrNull(1)
when {
name == null || !name.isString -> throw JsError.valueError("Header name must be a string")
value == null -> throw JsError.typeError("Header value is required")
else -> header(name.asString(), value.asString())
}
}

"send" -> ProxyExecutable {
val status = it.getOrNull(0)
val body = it.getOrNull(1)
when {
status == null || !status.fitsInInt() -> throw JsError.typeError("Status code must be an integer")
else -> send(status.asInt(), body)
}
}

"end" -> ProxyExecutable { this.end() }

else -> null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import io.netty.handler.codec.http.HttpRequest as NettyHttpRequest

// prepare the wrappers
val request = NettyHttpRequest(message)
val response = NettyHttpResponse(channelContext)
val response = NettyHttpResponse(channelContext, includeDefaults = true)
val context = HttpContext()

// resolve the handler pipeline (or default to 'not found' if empty)
Expand Down

0 comments on commit 20b7c5c

Please sign in to comment.