Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve HTTP tests #7847

Merged
merged 23 commits into from
Sep 27, 2023
Merged
Show file tree
Hide file tree
Changes from 15 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
36 changes: 29 additions & 7 deletions distribution/lib/Standard/Base/0.0.0-dev/src/Data.enso
Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,6 @@ fetch (uri:(URI | Text)) (method:HTTP_Method=HTTP_Method.Get) (headers:(Vector (
- try_auto_parse: If successful should the body be attempted to be parsed to
an Enso native object.

! Specifying Content Types

If the `body` parameter specifies an explicit content type, then it is an
error to also specify additional `Content-Type` headers in the `headers`
parameter. (It is not an error to specify multiple `Content-Type` values in
`headers`, however.)

! Supported Body Types

- Request_Body.Text: Sends a text string, with optional encoding and content
Expand All @@ -213,6 +206,35 @@ fetch (uri:(URI | Text)) (method:HTTP_Method=HTTP_Method.Get) (headers:(Vector (
- File: shorthand for `Request_Body.Binary that_file`.
- Any other Enso object: shorthand for `Request_Body.Json that_object`.

! Specifying Content Types

If the `body` parameter specifies an explicit content type, then it is an
error to also specify additional `Content-Type` headers in the `headers`
parameter. (It is not an error to specify multiple `Content-Type` values in
`headers`, however.)

! Default Content Types

The following specifies the default content type for each `Request_Body`
type.

- Request_Body.Text: `text/plain`
- Request_Body.Json: `application/json`
- Request_Body.Binary: `application/octet-stream`
- Request_Body.Form_Data:
If `url_encoded` is True: `application/x-www-form-urlencoded`
If `url_encoded` is False: `multipart/form-data`
- Request_Body.Empty: No content type is sent
- Text: `text/plain`
- File: `application/octet-stream`
- Any other Enso object: `application/json`

! Specifying Content Encodings

The content encoding specified in the `Request_Body` is used to encode the
body. The `headers` list can contain additional `Content-Encoding` headers,
and these are sent to the server, but not used for encoding.
GregoryTravis marked this conversation as resolved.
Show resolved Hide resolved

> Example
Write a text string to an HTTP endpoint.

Expand Down
145 changes: 96 additions & 49 deletions distribution/lib/Standard/Base/0.0.0-dev/src/Network/HTTP.enso
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import project.Any.Any
import project.Data.Map.Map
import project.Data.Pair.Pair
import project.Data.Set.Set
import project.Data.Text.Encoding.Encoding
import project.Data.Text.Text
import project.Data.Time.Duration.Duration
import project.Data.Vector.Vector
Expand Down Expand Up @@ -177,58 +178,59 @@ type HTTP
handle_request_error =
Panic.catch JException handler=(cause-> Error.throw (Request_Error.Error 'IllegalArgumentException' cause.payload.getMessage))

resolved_req = req.resolve_content_type
resolved_req.if_not_error <| Panic.recover Any <| handle_request_error <| check_output_context <|
Panic.recover Any <| handle_request_error <| check_output_context <|
body_publishers = HttpRequest.BodyPublishers
builder = HttpRequest.newBuilder

# set uri
uri = case resolved_req.uri of
_ : Text -> resolved_req.uri.to_uri
_ : URI -> resolved_req.uri
uri = case req.uri of
_ : Text -> req.uri.to_uri
_ : URI -> req.uri
builder.uri uri.internal_uri

# Generate body publisher, possibly generating additional headers
body_publisher_and_headers = case resolved_req.body of
Request_Body.Text text encoding _ ->
## We ignore the content_type field of the request body,
since any value it had was moved to the headers list in resolve_content_type
headers = [Header.new "Content-Encoding" encoding.character_set]
Pair.new (body_publishers.ofString text encoding.to_java_charset) headers
Request_Body.Json x ->
json = x.to_json
json.if_not_error <|
Pair.new (body_publishers.ofString json) [Header.application_json]
Request_Body.Binary file ->
path = Path.of file.path
Pair.new (body_publishers.ofFile path) [Header.application_octet_stream]
Request_Body.Form_Data form_data url_encoded ->
build_form_body_publisher form_data url_encoded
Request_Body.Empty ->
Pair.new (body_publishers.noBody) []
_ ->
Error.throw (Illegal_Argument.Error ("Unsupported POST body: " + resolved_req.body.to_display_text + "; this is a bug in the Data library"))

# Send request
body_publisher_and_headers.if_not_error <|
body_publisher = body_publisher_and_headers.first
additional_headers = body_publisher_and_headers.second

# set method and body
builder.method resolved_req.method.to_http_method_name body_publisher

# set headers
all_headers = resolved_req.headers + additional_headers
all_headers.map h-> builder.header h.name h.value

http_request = builder.build
body_handler = HttpResponse.BodyHandlers . ofByteArray

response = Response.Value (self.internal_http_client.send http_request body_handler)

if response.code.is_success.not then Error.throw (Request_Error.Error "Status Code" ("Request failed with status code: " + response.code.to_text + ". " + response.body.decode_as_text)) else
if try_auto_parse_response.not then response else
response.decode if_unsupported=response . catch handler=(_->response)
headers = resolve_headers req

headers.if_not_error <|
# Generate body publisher and optional form content boundary
body_publisher_and_boundary = case req.body of
Request_Body.Text text encoding _ ->
Pair.new (body_publishers.ofString text encoding.to_java_charset) Nothing
Request_Body.Json x ->
json = x.to_json
json.if_not_error <|
Pair.new (body_publishers.ofString json) Nothing
Request_Body.Binary file ->
path = Path.of file.path
Pair.new (body_publishers.ofFile path) Nothing
Request_Body.Form_Data form_data url_encoded ->
build_form_body_publisher form_data url_encoded
Request_Body.Empty ->
Pair.new (body_publishers.noBody) Nothing
_ ->
Error.throw (Illegal_Argument.Error ("Unsupported POST body: " + req.body.to_display_text + "; this is a bug in the Data library"))

# Send request
body_publisher_and_boundary.if_not_error <|
body_publisher = body_publisher_and_boundary.first
boundary = body_publisher_and_boundary.second

boundary_encoding_header_list = if boundary.is_nothing then [] else [Header.multipart_form_data boundary]

# set method and body
builder.method req.method.to_http_method_name body_publisher

# set headers
all_headers = headers + boundary_encoding_header_list
all_headers.map h-> builder.header h.name h.value

http_request = builder.build
body_handler = HttpResponse.BodyHandlers . ofByteArray

response = Response.Value (self.internal_http_client.send http_request body_handler)

if response.code.is_success.not then Error.throw (Request_Error.Error "Status Code" ("Request failed with status code: " + response.code.to_text + ". " + response.body.decode_as_text)) else
if try_auto_parse_response.not then response else
response.decode if_unsupported=response . catch handler=(_->response)

## PRIVATE

Expand Down Expand Up @@ -289,26 +291,71 @@ parse_headers headers =
_ : Header -> h
_ -> Error.throw (Illegal_Argument.Error "Invalid header type - all values must be Vector, Pair or Header (got "+(Meta.get_simple_type_name h)+").")

## PRIVATE
It is an error to specify the content type in both the request body and the header list.
If the body is not Request_Body.Empty, and no content type or content encoding is specified, a default is used.
resolve_headers : Request -> Vector Header
resolve_headers req =
is_content_type_header h = h.name . equals_ignore_case Header.content_type_header_name
is_content_encoding_header h = h.name . equals_ignore_case Header.content_encoding_header_name

# Check for content type and encoding the Request_Body
request_body_content_type = case req.body of
Request_Body.Text _ _ content_type -> content_type
_ -> Nothing
request_body_content_encoding = case req.body of
Request_Body.Text _ content_encoding _ -> content_encoding
_ -> Nothing

## Raise error if content type is specified in two places;
otherwise, add any Request_Body settings to the header list.
has_content_type_header_in_list = req.headers.any is_content_type_header

all_headers = case request_body_content_type.is_nothing.not && has_content_type_header_in_list of
True -> Error.throw (Illegal_Argument.Error "Cannot specify Content-Type in both the request body and request headers")
False ->
content_type_additions = if request_body_content_type.is_nothing then [] else [Header.content_type request_body_content_type]
content_encoding_additions = if request_body_content_encoding.is_nothing then [] else [Header.content_encoding request_body_content_encoding.character_set]
req.headers + content_type_additions + content_encoding_additions

all_headers.if_not_error <|
# Add default content type and/or encoding, if one is not specified and the body is not Request_Body.Empty.
contains_content_type = all_headers.any is_content_type_header
contains_content_encoding = all_headers.any is_content_encoding_header
is_body_empty = case req.body of
Request_Body.Empty -> True
_ -> False
is_body_text = case req.body of
Request_Body.Text _ _ _ -> True
_ -> False
default_content_type = if is_body_empty || contains_content_type then [] else
default = req.body.default_content_type_header
if default.is_nothing then [] else [default]
default_content_encoding = if is_body_text && contains_content_encoding.not then [Header.content_encoding Encoding.utf_8.character_set] else []

all_headers + default_content_type + default_content_encoding

## PRIVATE

Build a BodyPublisher from the given form data.
build_form_body_publisher : Map Text (Text | File) -> Boolean -> Pair BodyPublisher [Header]
The pair's second value is a content boundary in the case of a `multipart/form-data` form; otherwise, Nothing
build_form_body_publisher : Map Text (Text | File) -> Boolean -> Pair BodyPublisher Text
build_form_body_publisher (form_data:(Map Text (Text | File))) (url_encoded:Boolean=False) = case url_encoded of
True ->
body_builder = Http_Utils.urlencoded_body_builder
form_data.map_with_key key-> value->
case value of
_ : Text -> body_builder.add_part_text key value
_ : File -> body_builder.add_part_file key value.path
Pair.new body_builder.build []
Pair.new body_builder.build Nothing
False ->
body_builder = Http_Utils.multipart_body_builder
form_data.map_with_key key-> value->
case value of
_ : Text -> body_builder.add_part_text key value
_ : File -> body_builder.add_part_file key value.path
boundary = body_builder.get_boundary
Pair.new body_builder.build [Header.multipart_form_data boundary]
Pair.new body_builder.build boundary

## PRIVATE
fetch_methods : Set HTTP_Method
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,20 @@ type Header
content_type : Text -> Header
content_type value = Header.Value Header.content_type_header_name value

## Create "Content-Encoding" header.

Arguments:
- value: The value for the content encoding header.

> Example
Create a content encoding header containing "my_encoding".

import Standard.Base.Network.HTTP.Header.Header

example_content_type = Header.content_encoding "my_encoding"
content_encoding : Text -> Header
content_encoding value = Header.Value Header.content_encoding_header_name value

## Header "Content-Type: application/json".

> Example
Expand Down Expand Up @@ -181,6 +195,9 @@ type Header
content_type_header_name : Text
content_type_header_name = "Content-Type"

content_encoding_header_name : Text
content_encoding_header_name = "Content-Encoding"

## PRIVATE
type Header_Comparator
## PRIVATE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,37 +242,3 @@ type Request
_ : Map -> parts
new_body = Request_Body.Form_Data form_data
Request.Value self.method self.uri self.headers new_body . with_headers [Header.application_x_www_form_urlencoded]

## PRIVATE

Canonicalize any `Content-Type` values specified in the request body and header list.
It is an error to specify content type in both the body and the header list.
If neither is specified, then content type defaults to 'text/plain'.
(Multiple `Content-Type` headers in the header list is not an error.)

The effect of this method is to check for errors, then move the body
content type, if any, to the headers list.
resolve_content_type : Request
resolve_content_type self =
is_content_type_header header = header.name.equals_ignore_case Header.content_type_header_name

body_content_type = case self.body of
Request_Body.Text _ _ content_type -> content_type
_ -> Nothing
header_content_types = self.headers.filter is_content_type_header

case body_content_type.is_nothing of
True -> case header_content_types.is_empty of
True ->
self.with_header Header.content_type_header_name "text/plain"
False ->
self
False -> case header_content_types.is_empty of
True ->
header = Header.content_type self.body.content_type
new_body = case self.body of
Request_Body.Text text encoding _ -> Request_Body.Text text encoding Nothing
_ -> Panic.throw (Illegal_State.Error "Unexpected request body content type. This is a bug in the HTTP library.")
Request.new self.method self.uri self.headers new_body . with_header header.name header.value
False ->
Error.throw (Illegal_Argument.Error "Cannot specify Content-Type in both the request body and request headers")
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import project.Any.Any
import project.Data.Map.Map
import project.Data.Text.Encoding.Encoding
import project.Data.Text.Text
import project.Network.HTTP.Header.Header
import project.Nothing.Nothing
import project.System.File.File
from project.Data.Boolean import Boolean, False, True
Expand Down Expand Up @@ -40,6 +41,18 @@ type Request_Body
## Empty request body; used for GET
Empty

## PRIVATE
For `Request_Body.Form_Data url_encoded=False`, this returns `Nothing`,
because the content type must include a content boundary.
GregoryTravis marked this conversation as resolved.
Show resolved Hide resolved
default_content_type_header : Header | Nothing
default_content_type_header self =
case self of
Request_Body.Text _ _ _ -> Header.content_type "text/plain"
Request_Body.Json _ -> Header.content_type "application/json"
Request_Body.Binary _ -> Header.content_type "application/octet-stream"
Request_Body.Form_Data _ url_encoded -> if url_encoded then Header.application_x_www_form_urlencoded else Nothing
Request_Body.Empty -> Nothing

Request_Body.from (that:Text) = Request_Body.Text that
Request_Body.from (that:File) = Request_Body.Binary that
# TODO: determine if this is needed.
Expand Down
Loading