Skip to content

Commit

Permalink
feat: implement Response.json static method (#499)
Browse files Browse the repository at this point in the history
Co-authored-by: Trevor Elliott <telliott@fastly.com>
  • Loading branch information
JakeChampion and elliottt committed Apr 25, 2023
1 parent 1a9be1c commit 780067d
Show file tree
Hide file tree
Showing 11 changed files with 313 additions and 4 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/main.yml
Expand Up @@ -248,6 +248,7 @@ jobs:
- request-upstream
- response
- response-headers
- response-json
- response-redirect
- secret-store
- status
Expand Down Expand Up @@ -396,6 +397,7 @@ jobs:
- 'request-upstream'
- 'response'
- 'response-headers'
- 'response-json'
- 'response-redirect'
- 'secret-store'
- 'status'
Expand Down
154 changes: 153 additions & 1 deletion c-dependencies/js-compute-runtime/builtins/request-response.cpp
Expand Up @@ -2373,7 +2373,7 @@ bool Response::redirect(JSContext *cx, unsigned argc, JS::Value *vp) {
if (!headers) {
return false;
}
if (!builtins::Headers::maybe_add(cx, headers, "Location", value)) {
if (!builtins::Headers::maybe_add(cx, headers, "location", value)) {
return false;
}
JS::SetReservedSlot(response, static_cast<uint32_t>(Slots::Headers), JS::ObjectValue(*headers));
Expand All @@ -2384,8 +2384,160 @@ bool Response::redirect(JSContext *cx, unsigned argc, JS::Value *vp) {
return true;
}

namespace {
bool callbackCalled;
bool write_json_to_buf(const char16_t *str, uint32_t strlen, void *out) {
callbackCalled = true;
auto outstr = static_cast<std::u16string *>(out);
outstr->append(str, strlen);

return true;
}
} // namespace

bool Response::json(JSContext *cx, unsigned argc, JS::Value *vp) {
JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
if (!args.requireAtLeast(cx, "json", 1)) {
return false;
}
JS::RootedValue data(cx, args.get(0));
JS::RootedValue init_val(cx, args.get(1));
JS::RootedObject replacer(cx);
JS::RootedValue space(cx);

std::u16string out;
// 1. Let bytes the result of running serialize a JavaScript value to JSON bytes on data.
callbackCalled = false;
if (!JS::ToJSON(cx, data, replacer, space, &write_json_to_buf, &out)) {
return false;
}
if (!callbackCalled) {
JS_ReportErrorNumberASCII(cx, GetErrorMessage, nullptr, JSMSG_RESPONSE_JSON_INVALID_VALUE);
return false;
}
// 2. Let body be the result of extracting bytes.

// 3. Let responseObject be the result of creating a Response object, given a new response,
// "response", and this’s relevant Realm.
JS::RootedValue status_val(cx);
uint16_t status = 200;

JS::RootedValue statusText_val(cx);
JS::RootedString statusText(cx, JS_GetEmptyString(cx));
JS::RootedValue headers_val(cx);

if (init_val.isObject()) {
JS::RootedObject init(cx, init_val.toObjectOrNull());
if (!JS_GetProperty(cx, init, "status", &status_val) ||
!JS_GetProperty(cx, init, "statusText", &statusText_val) ||
!JS_GetProperty(cx, init, "headers", &headers_val)) {
return false;
}

if (!status_val.isUndefined() && !JS::ToUint16(cx, status_val, &status)) {
return false;
}

if (status == 204 || status == 205 || status == 304) {
JS_ReportErrorNumberASCII(cx, GetErrorMessage, nullptr,
JSMSG_RESPONSE_NULL_BODY_STATUS_WITH_BODY);
return false;
}

if (!statusText_val.isUndefined() && !(statusText = JS::ToString(cx, statusText_val))) {
return false;
}

} else if (!init_val.isNullOrUndefined()) {
JS_ReportErrorLatin1(cx, "Response constructor: |init| parameter can't be converted to "
"a dictionary");
return false;
}

fastly_response_handle_t response_handle = INVALID_HANDLE;
fastly_error_t err;
if (!fastly_http_resp_new(&response_handle, &err)) {
HANDLE_ERROR(cx, err);
return false;
}
if (response_handle == INVALID_HANDLE) {
return false;
}

auto make_res = HttpBody::make();
if (auto *err = make_res.to_err()) {
HANDLE_ERROR(cx, *err);
return false;
}

auto body = make_res.unwrap();
JS::RootedString string(cx, JS_NewUCStringCopyN(cx, out.c_str(), out.length()));
size_t encoded_len;
auto stringChars = encode(cx, string, &encoded_len);

auto write_res = body.write_all(reinterpret_cast<uint8_t *>(stringChars.get()), encoded_len);
if (auto *err = write_res.to_err()) {
HANDLE_ERROR(cx, *err);
return false;
}
JS::RootedObject response_instance(cx, JS_NewObjectWithGivenProto(cx, &builtins::Response::class_,
builtins::Response::proto_obj));
if (!response_instance) {
return false;
}
JS::RootedObject response(cx, create(cx, response_instance, response_handle, body.handle, false));
if (!response) {
return false;
}

// Set `this`’s `response`’s `status` to `init`["status"].
if (!fastly_http_resp_status_set(response_handle, status, &err)) {
HANDLE_ERROR(cx, err);
return false;
}
// To ensure that we really have the same status value as the host,
// we always read it back here.
if (!fastly_http_resp_status_get(response_handle, &status, &err)) {
HANDLE_ERROR(cx, err);
return false;
}

JS::SetReservedSlot(response, static_cast<uint32_t>(Slots::Status), JS::Int32Value(status));

// Set `this`’s `response`’s `status message` to `init`["statusText"].
JS::SetReservedSlot(response, static_cast<uint32_t>(Slots::StatusMessage),
JS::StringValue(statusText));

// If `init`["headers"] `exists`, then `fill` `this`’s `headers` with
// `init`["headers"].
JS::RootedObject headers(cx);
JS::RootedObject headersInstance(
cx, JS_NewObjectWithGivenProto(cx, &builtins::Headers::class_, builtins::Headers::proto_obj));
if (!headersInstance)
return false;

headers = builtins::Headers::create(cx, headersInstance, builtins::Headers::Mode::ProxyToResponse,
response, headers_val);
if (!headers) {
return false;
}
// 4. Perform initialize a response given responseObject, init, and (body, "application/json").
if (!builtins::Headers::maybe_add(cx, headers, "content-type", "application/json")) {
return false;
}
JS::SetReservedSlot(response, static_cast<uint32_t>(Slots::Headers), JS::ObjectValue(*headers));
JS::SetReservedSlot(response, static_cast<uint32_t>(Slots::Redirected), JS::FalseValue());
JS::SetReservedSlot(response, static_cast<uint32_t>(Slots::HasBody), JS::TrueValue());
RequestOrResponse::set_url(response, JS_GetEmptyStringValue(cx));

// 5. Return responseObject.
args.rval().setObjectOrNull(response);
return true;
}

const JSFunctionSpec Response::static_methods[] = {
JS_FN("redirect", redirect, 1, JSPROP_ENUMERATE),
JS_FN("json", json, 1, JSPROP_ENUMERATE),
JS_FS_END,
};

Expand Down
Expand Up @@ -179,6 +179,9 @@ class Response final : public BuiltinImpl<Response> {
static bool body_get(JSContext *cx, unsigned argc, JS::Value *vp);
static bool bodyUsed_get(JSContext *cx, unsigned argc, JS::Value *vp);

static bool redirect(JSContext *cx, unsigned argc, JS::Value *vp);
static bool json(JSContext *cx, unsigned argc, JS::Value *vp);

public:
static constexpr const char *class_name = "Response";

Expand All @@ -205,7 +208,6 @@ class Response final : public BuiltinImpl<Response> {
static bool init_class(JSContext *cx, JS::HandleObject global);
static bool constructor(JSContext *cx, unsigned argc, JS::Value *vp);

static bool redirect(JSContext *cx, unsigned argc, JS::Value *vp);
static JSObject *create(JSContext *cx, JS::HandleObject response,
fastly_response_handle_t response_handle,
fastly_body_handle_t body_handle, bool is_upstream);
Expand Down
2 changes: 2 additions & 0 deletions c-dependencies/js-compute-runtime/error-numbers.msg
Expand Up @@ -105,4 +105,6 @@ MSG_DEF(JSMSG_SUBTLE_CRYPTO_INVALID_JWK_KTY_VALUE, 1, JSEXN_ERR, "Th
MSG_DEF(JSMSG_SUBTLE_CRYPTO_INVALID_KEY_USAGES_VALUE, 0, JSEXN_TYPEERR, "Invalid keyUsages argument")
MSG_DEF(JSMSG_RESPONSE_REDIRECT_INVALID_URI, 0, JSEXN_TYPEERR, "Response.redirect: url parameter is not a valid URL.")
MSG_DEF(JSMSG_RESPONSE_REDIRECT_INVALID_STATUS, 0, JSEXN_RANGEERR, "Response.redirect: Invalid redirect status code.")
MSG_DEF(JSMSG_RESPONSE_NULL_BODY_STATUS_WITH_BODY, 0, JSEXN_TYPEERR, "Response with null body status cannot have body")
MSG_DEF(JSMSG_RESPONSE_JSON_INVALID_VALUE, 0, JSEXN_TYPEERR, "Redirect.json: The data is not JSON serializable")
//clang-format on
85 changes: 85 additions & 0 deletions integration-tests/js-compute/fixtures/response-json/bin/index.js
@@ -0,0 +1,85 @@
/* eslint-env serviceworker */
import { pass, assert, assertThrows } from "../../../assertions.js";
import { routes } from "../../../test-harness.js";

let error;
routes.set("/response/json", async () => {
const APPLICATION_JSON = "application/json";
const FOO_BAR = "foo/bar";

const INIT_TESTS = [
[undefined, 200, "", APPLICATION_JSON, {}],
[{ status: 400 }, 400, "", APPLICATION_JSON, {}],
[{ statusText: "foo" }, 200, "foo", APPLICATION_JSON, {}],
[{ headers: {} }, 200, "", APPLICATION_JSON, {}],
[{ headers: { "content-type": FOO_BAR } }, 200, "", FOO_BAR, {}],
[{ headers: { "x-foo": "bar" } }, 200, "", APPLICATION_JSON, { "x-foo": "bar" }],
];

for (const [init, expectedStatus, expectedStatusText, expectedContentType, expectedHeaders] of INIT_TESTS) {
const response = Response.json("hello world", init);
error = assert(response.type, "default", 'response.type');
if (error) { return error; }
error = assert(response.status, expectedStatus, 'response.status');
if (error) { return error; }
error = assert(response.statusText, expectedStatusText, 'response.statusText');
if (error) { return error; }
error = assert(response.headers.get("content-type"), expectedContentType, 'response.headers.get("content-type")');
if (error) { return error; }
for (const key in expectedHeaders) {
error = assert(response.headers.get(key), expectedHeaders[key], 'response.headers.get(key)');
if (error) { return error; }
}
const data = await response.json();
error = assert(data, "hello world", 'data');
if (error) { return error; }
}

const nullBodyStatus = [204, 205, 304];
for (const status of nullBodyStatus) {
error = assertThrows(
function () {
Response.json("hello world", { status: status });
},
TypeError,
);
if (error) { return error; }
}

const response = Response.json({ foo: "bar" });
const data = await response.json();
error = assert(typeof data, "object", 'typeof data');
if (error) { return error; }
error = assert(data.foo, "bar", "data.foo");
if (error) { return error; }

error = assertThrows(
function () {
Response.json(Symbol("foo"));
},
TypeError
);
if (error) { return error; }

const a = { b: 1 };
a.a = a;
error = assertThrows(
function () {
Response.json(a);
},
TypeError,
);
if (error) { return error; }

class CustomError extends Error {
name = "CustomError";
}
error = assertThrows(
function () {
Response.json({ get foo() { throw new CustomError("bar") } });
},
CustomError,
)
if (error) { return error; }
return pass()
});
12 changes: 12 additions & 0 deletions integration-tests/js-compute/fixtures/response-json/fastly.toml.in
@@ -0,0 +1,12 @@
# This file describes a Fastly Compute@Edge package. To learn more visit:
# https://developer.fastly.com/reference/fastly-toml/

authors = ["jchampion@fastly.com"]
description = ""
language = "other"
manifest_version = 2
name = "response-json"
service_id = ""

[scripts]
build = "node ../../../../js-compute-runtime-cli.js"
12 changes: 12 additions & 0 deletions integration-tests/js-compute/fixtures/response-json/tests.json
@@ -0,0 +1,12 @@
{
"GET /response/json": {
"environments": ["viceroy", "c@e"],
"downstream_request": {
"method": "GET",
"pathname": "/response/json"
},
"downstream_response": {
"status": 200
}
}
}
@@ -1,6 +1,6 @@
/* eslint-env serviceworker */
import { pass, assert, assertThrows } from "../../../assertions.js";
import { routes } from "./test-harness.js";
import { routes } from "../../../test-harness.js";

routes.set("/response/redirect", async () => {
const url = "http://test.url:1234/";
Expand Down
@@ -1,6 +1,6 @@
/* eslint-env serviceworker */
import { env } from 'fastly:env';
import { fail } from "../../../assertions.js";
import { fail } from "./assertions.js";

addEventListener("fetch", event => {
event.respondWith(app(event))
Expand Down
@@ -0,0 +1,41 @@
{
"Check response returned by static json() with init undefined": {
"status": "PASS"
},
"Check response returned by static json() with init {\"status\":400}": {
"status": "PASS"
},
"Check response returned by static json() with init {\"statusText\":\"foo\"}": {
"status": "PASS"
},
"Check response returned by static json() with init {\"headers\":{}}": {
"status": "PASS"
},
"Check response returned by static json() with init {\"headers\":{\"content-type\":\"foo/bar\"}}": {
"status": "PASS"
},
"Check response returned by static json() with init {\"headers\":{\"x-foo\":\"bar\"}}": {
"status": "PASS"
},
"Throws TypeError when calling static json() with a status of 204": {
"status": "PASS"
},
"Throws TypeError when calling static json() with a status of 205": {
"status": "PASS"
},
"Throws TypeError when calling static json() with a status of 304": {
"status": "PASS"
},
"Check static json() encodes JSON objects correctly": {
"status": "PASS"
},
"Check static json() throws when data is not encodable": {
"status": "PASS"
},
"Check static json() throws when data is circular": {
"status": "PASS"
},
"Check static json() propagates JSON serializer errors": {
"status": "PASS"
}
}
1 change: 1 addition & 0 deletions tests/wpt-harness/tests.json
Expand Up @@ -97,6 +97,7 @@
"fetch/api/response/response-init-002.any.js",
"fetch/api/response/response-init-contenttype.any.js",
"fetch/api/response/response-static-error.any.js",
"fetch/api/response/response-static-json.any.js",
"fetch/api/response/response-static-redirect.any.js",
"fetch/api/response/response-stream-disturbed-1.any.js",
"fetch/api/response/response-stream-disturbed-2.any.js",
Expand Down

0 comments on commit 780067d

Please sign in to comment.