mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 23:31:42 +03:00
Add tests for Enso Cloud auth + simple API mock for Enso_User
(#8511)
- Closes #8354 - Extends `simple-httpbin` with a simple mock of the Cloud API (currently it checks the token and serves the `/users` endpoint). - Renames `simple-httpbin` to `http-test-helper`.
This commit is contained in:
parent
aa05389f4a
commit
724f8d2a56
@ -21,7 +21,7 @@ resources/python
|
|||||||
# The files in the `data` directory of our tests may have specific structure or
|
# The files in the `data` directory of our tests may have specific structure or
|
||||||
# even be malformed on purpose, so we do not want to run prettier on them.
|
# even be malformed on purpose, so we do not want to run prettier on them.
|
||||||
test/**/data
|
test/**/data
|
||||||
tools/simple-httpbin/www-files
|
tools/http-test-helper/www-files
|
||||||
|
|
||||||
# GUI
|
# GUI
|
||||||
**/scala-parser.js
|
**/scala-parser.js
|
||||||
|
@ -318,7 +318,7 @@ lazy val enso = (project in file("."))
|
|||||||
`std-image`,
|
`std-image`,
|
||||||
`std-table`,
|
`std-table`,
|
||||||
`std-aws`,
|
`std-aws`,
|
||||||
`simple-httpbin`,
|
`http-test-helper`,
|
||||||
`enso-test-java-helpers`,
|
`enso-test-java-helpers`,
|
||||||
`exploratory-benchmark-java-helpers`,
|
`exploratory-benchmark-java-helpers`,
|
||||||
`benchmark-java-helpers`,
|
`benchmark-java-helpers`,
|
||||||
@ -2909,13 +2909,13 @@ val stdBitsProjects =
|
|||||||
val allStdBits: Parser[String] =
|
val allStdBits: Parser[String] =
|
||||||
stdBitsProjects.map(v => v: Parser[String]).reduce(_ | _)
|
stdBitsProjects.map(v => v: Parser[String]).reduce(_ | _)
|
||||||
|
|
||||||
lazy val `simple-httpbin` = project
|
lazy val `http-test-helper` = project
|
||||||
.in(file("tools") / "simple-httpbin")
|
.in(file("tools") / "http-test-helper")
|
||||||
.settings(
|
.settings(
|
||||||
customFrgaalJavaCompilerSettings(targetJdk = "21"),
|
customFrgaalJavaCompilerSettings(targetJdk = "21"),
|
||||||
autoScalaLibrary := false,
|
autoScalaLibrary := false,
|
||||||
Compile / javacOptions ++= Seq("-Xlint:all"),
|
Compile / javacOptions ++= Seq("-Xlint:all"),
|
||||||
Compile / run / mainClass := Some("org.enso.shttp.SimpleHTTPBin"),
|
Compile / run / mainClass := Some("org.enso.shttp.HTTPTestHelperServer"),
|
||||||
assembly / mainClass := (Compile / run / mainClass).value,
|
assembly / mainClass := (Compile / run / mainClass).value,
|
||||||
libraryDependencies ++= Seq(
|
libraryDependencies ++= Seq(
|
||||||
"org.apache.commons" % "commons-text" % commonsTextVersion,
|
"org.apache.commons" % "commons-text" % commonsTextVersion,
|
||||||
|
@ -29,7 +29,7 @@ pub async fn get_and_spawn_httpbin(
|
|||||||
) -> Result<Spawned> {
|
) -> Result<Spawned> {
|
||||||
let process = sbt
|
let process = sbt
|
||||||
.command()?
|
.command()?
|
||||||
.arg(format!("simple-httpbin/run localhost {port}"))
|
.arg(format!("http-test-helper/run localhost {port}"))
|
||||||
.kill_on_drop(true)
|
.kill_on_drop(true)
|
||||||
.spawn()?;
|
.spawn()?;
|
||||||
|
|
||||||
|
@ -30,7 +30,7 @@ type Enso_User
|
|||||||
response = HTTP.fetch user_api HTTP_Method.Get [auth_header]
|
response = HTTP.fetch user_api HTTP_Method.Get [auth_header]
|
||||||
response.if_not_error <|
|
response.if_not_error <|
|
||||||
js_object = response.decode_as_json
|
js_object = response.decode_as_json
|
||||||
js_object.into Enso_User
|
Enso_User.from js_object
|
||||||
|
|
||||||
## Lists all known users.
|
## Lists all known users.
|
||||||
list : Vector Enso_User
|
list : Vector Enso_User
|
||||||
@ -41,7 +41,7 @@ type Enso_User
|
|||||||
response.if_not_error <|
|
response.if_not_error <|
|
||||||
js_object = response.decode_as_json
|
js_object = response.decode_as_json
|
||||||
users = js_object.get 'users' []
|
users = js_object.get 'users' []
|
||||||
users.map (user-> user.into Enso_User)
|
users.map (user-> Enso_User.from user)
|
||||||
|
|
||||||
## PRIVATE
|
## PRIVATE
|
||||||
Enso_User.from (that:JS_Object) = if ["name", "email", "id"].any (k-> that.contains_key k . not) then Error.throw (Illegal_Argument.Error "Invalid JSON for an Enso_User.") else
|
Enso_User.from (that:JS_Object) = if ["name", "email", "id"].any (k-> that.contains_key k . not) then Error.throw (Illegal_Argument.Error "Invalid JSON for an Enso_User.") else
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import project.Data.Pair.Pair
|
import project.Data.Pair.Pair
|
||||||
import project.Data.Text.Text
|
import project.Data.Text.Text
|
||||||
import project.Error.Error
|
import project.Error.Error
|
||||||
|
import project.Network.HTTP.Header.Header
|
||||||
import project.Nothing.Nothing
|
import project.Nothing.Nothing
|
||||||
import project.Runtime.Ref.Ref
|
import project.Runtime.Ref.Ref
|
||||||
|
import project.System.Environment
|
||||||
import project.System.File.File
|
import project.System.File.File
|
||||||
|
|
||||||
polyglot java import org.enso.base.enso_cloud.AuthenticationProvider
|
polyglot java import org.enso.base.enso_cloud.AuthenticationProvider
|
||||||
@ -11,14 +13,20 @@ polyglot java import org.enso.base.enso_cloud.AuthenticationProvider
|
|||||||
cloud_root_uri = "" + AuthenticationProvider.getAPIRootURI
|
cloud_root_uri = "" + AuthenticationProvider.getAPIRootURI
|
||||||
|
|
||||||
## PRIVATE
|
## PRIVATE
|
||||||
Construct the authoization header for the request
|
Construct the authorization header for the request
|
||||||
authorization_header : Pair Text Text
|
authorization_header : Header
|
||||||
authorization_header =
|
authorization_header =
|
||||||
result = AuthenticationProvider.getToken.if_nothing <|
|
token = AuthenticationProvider.getToken.if_nothing <|
|
||||||
cred_file = File.home / ".enso" / "credentials"
|
f = credentials_file
|
||||||
if cred_file.exists.not then Error.throw Not_Logged_In else
|
if f.exists.not then Error.throw Not_Logged_In else
|
||||||
AuthenticationProvider.setToken (cred_file.read_text)
|
AuthenticationProvider.setToken (f.read_text)
|
||||||
Pair.new "Authorization" "Bearer "+result
|
Header.authorization_bearer token
|
||||||
|
|
||||||
|
## PRIVATE
|
||||||
|
credentials_file : File
|
||||||
|
credentials_file = case Environment.get "ENSO_CLOUD_CREDENTIALS_FILE" of
|
||||||
|
Nothing -> File.home / ".enso" / "credentials"
|
||||||
|
path -> File.new path
|
||||||
|
|
||||||
## PRIVATE
|
## PRIVATE
|
||||||
Root address for listing folders
|
Root address for listing folders
|
||||||
|
@ -6,6 +6,7 @@ import project.Data.Set.Set
|
|||||||
import project.Data.Text.Encoding.Encoding
|
import project.Data.Text.Encoding.Encoding
|
||||||
import project.Data.Text.Text
|
import project.Data.Text.Text
|
||||||
import project.Data.Time.Duration.Duration
|
import project.Data.Time.Duration.Duration
|
||||||
|
import project.Data.Vector.No_Wrap
|
||||||
import project.Data.Vector.Vector
|
import project.Data.Vector.Vector
|
||||||
import project.Error.Error
|
import project.Error.Error
|
||||||
import project.Errors.Common.Forbidden_Operation
|
import project.Errors.Common.Forbidden_Operation
|
||||||
@ -117,7 +118,7 @@ type HTTP
|
|||||||
boundary = body_publisher_and_boundary.second
|
boundary = body_publisher_and_boundary.second
|
||||||
boundary_header_list = if boundary.is_nothing then [] else [Header.multipart_form_data boundary]
|
boundary_header_list = if boundary.is_nothing then [] else [Header.multipart_form_data boundary]
|
||||||
all_headers = headers + boundary_header_list
|
all_headers = headers + boundary_header_list
|
||||||
mapped_headers = all_headers.map h-> case h.value of
|
mapped_headers = all_headers.map on_problems=No_Wrap.Value h-> case h.value of
|
||||||
_ : Enso_Secret -> EnsoKeyValuePair.ofSecret h.name h.value.id
|
_ : Enso_Secret -> EnsoKeyValuePair.ofSecret h.name h.value.id
|
||||||
_ -> EnsoKeyValuePair.ofText h.name h.value
|
_ -> EnsoKeyValuePair.ofText h.name h.value
|
||||||
|
|
||||||
@ -178,7 +179,7 @@ type HTTP
|
|||||||
## PRIVATE
|
## PRIVATE
|
||||||
parse_headers : Vector (Header | Pair Text Text) -> Vector Header
|
parse_headers : Vector (Header | Pair Text Text) -> Vector Header
|
||||||
parse_headers headers =
|
parse_headers headers =
|
||||||
headers . map h-> case h of
|
headers . map on_problems=No_Wrap.Value h-> case h of
|
||||||
_ : Vector -> Header.new (h.at 0) (h.at 1)
|
_ : Vector -> Header.new (h.at 0) (h.at 1)
|
||||||
_ : Pair -> Header.new (h.at 0) (h.at 1)
|
_ : Pair -> Header.new (h.at 0) (h.at 1)
|
||||||
_ : Header -> h
|
_ : Header -> h
|
||||||
|
@ -97,6 +97,7 @@ type Header
|
|||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
- token: The token.
|
- token: The token.
|
||||||
|
authorization_bearer : Text -> Header
|
||||||
authorization_bearer token:Text =
|
authorization_bearer token:Text =
|
||||||
Header.authorization ("Bearer " + token)
|
Header.authorization ("Bearer " + token)
|
||||||
|
|
||||||
|
@ -12,4 +12,11 @@ polyglot java import org.enso.base.Environment_Utils
|
|||||||
`System.getenv` Java call remains unchanged.
|
`System.getenv` Java call remains unchanged.
|
||||||
unsafe_with_environment_override : Text -> Text -> Any -> Any
|
unsafe_with_environment_override : Text -> Text -> Any -> Any
|
||||||
unsafe_with_environment_override key value ~action =
|
unsafe_with_environment_override key value ~action =
|
||||||
Environment_Utils.with_environment_variable_override key value (_->action)
|
## This has to be done in Enso, not in Java, due to the bug: https://github.com/enso-org/enso/issues/7117
|
||||||
|
If done in Java, Enso test functions do not work correctly, because they cannot access State.
|
||||||
|
old_value = Environment_Utils.getOverride key
|
||||||
|
restore_previous =
|
||||||
|
if old_value.is_nothing then Environment_Utils.removeOverride key else Environment_Utils.setOverride key old_value
|
||||||
|
Panic.with_finalizer restore_previous <|
|
||||||
|
Environment_Utils.setOverride key value
|
||||||
|
action
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package org.enso.base;
|
package org.enso.base;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.function.Function;
|
|
||||||
|
|
||||||
public class Environment_Utils {
|
public class Environment_Utils {
|
||||||
/** Gets the environment variable, including any overrides. */
|
/** Gets the environment variable, including any overrides. */
|
||||||
@ -14,30 +13,16 @@ public class Environment_Utils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public static void setOverride(String name, String value) {
|
||||||
* Calls `action` with the provided environment variable.
|
overrides.put(name, value);
|
||||||
*
|
}
|
||||||
* <p>The override is not persisted (its only visible from within the action called by this
|
|
||||||
* method) and it is only visible by the Enso `Environment.get` method (backed by {@code
|
public static void removeOverride(String name) {
|
||||||
* get_environment_variable}).
|
overrides.remove(name);
|
||||||
*
|
}
|
||||||
* <p>This is an internal function that should be used very carefully and only for testing.
|
|
||||||
*/
|
public static String getOverride(String name) {
|
||||||
public static <T> T with_environment_variable_override(
|
return overrides.get(name);
|
||||||
String name, String value, Function<Object, T> action) {
|
|
||||||
String oldValue = overrides.put(name, value);
|
|
||||||
boolean was_set = oldValue != null;
|
|
||||||
try {
|
|
||||||
// Giving 0 here as an argument, as using null would lead to incorrect behaviour, due to some
|
|
||||||
// weird Truffle peculiarity.
|
|
||||||
return action.apply(0);
|
|
||||||
} finally {
|
|
||||||
if (was_set) {
|
|
||||||
overrides.put(name, oldValue);
|
|
||||||
} else {
|
|
||||||
overrides.remove(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final HashMap<String, String> overrides = new HashMap<>();
|
private static final HashMap<String, String> overrides = new HashMap<>();
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
package org.enso.base.enso_cloud;
|
package org.enso.base.enso_cloud;
|
||||||
|
|
||||||
|
import org.enso.base.Environment_Utils;
|
||||||
|
|
||||||
public class AuthenticationProvider {
|
public class AuthenticationProvider {
|
||||||
private static String token;
|
private static String token = null;
|
||||||
|
|
||||||
public static String setToken(String token) {
|
public static String setToken(String token) {
|
||||||
AuthenticationProvider.token = token;
|
AuthenticationProvider.token = token;
|
||||||
@ -13,7 +15,7 @@ public class AuthenticationProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static String getAPIRootURI() {
|
public static String getAPIRootURI() {
|
||||||
var envUri = System.getenv("ENSO_CLOUD_API_URI");
|
var envUri = Environment_Utils.get_environment_variable("ENSO_CLOUD_API_URI");
|
||||||
return envUri == null ? "https://7aqkn3tnbc.execute-api.eu-west-1.amazonaws.com/" : envUri;
|
return envUri == null ? "https://7aqkn3tnbc.execute-api.eu-west-1.amazonaws.com/" : envUri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ main = Test_Suite.run_main spec
|
|||||||
|
|
||||||
spec =
|
spec =
|
||||||
## To run this test locally:
|
## To run this test locally:
|
||||||
$ sbt 'simple-httpbin/run localhost 8080'
|
$ sbt 'http-test-helper/run localhost 8080'
|
||||||
$ export ENSO_HTTP_TEST_HTTPBIN_URL=http://localhost:8080/
|
$ export ENSO_HTTP_TEST_HTTPBIN_URL=http://localhost:8080/
|
||||||
base_url = Environment.get "ENSO_HTTP_TEST_HTTPBIN_URL"
|
base_url = Environment.get "ENSO_HTTP_TEST_HTTPBIN_URL"
|
||||||
base_url_with_slash = base_url.if_not_nothing <|
|
base_url_with_slash = base_url.if_not_nothing <|
|
||||||
|
@ -5,5 +5,6 @@ the localhost. If it is present, the port it listens to should be provided by
|
|||||||
setting the `ENSO_HTTP_TEST_HTTPBIN_URL` environment variable to a value like
|
setting the `ENSO_HTTP_TEST_HTTPBIN_URL` environment variable to a value like
|
||||||
`http://localhost:8080`. The URL may contain a trailing slash.
|
`http://localhost:8080`. The URL may contain a trailing slash.
|
||||||
|
|
||||||
`tools/simple-httpbin` provides a simple implementation of `httpbin` server. See
|
`tools/http-test-helper` provides a simple implementation of `httpbin` server,
|
||||||
its README for instructions on how to run it.
|
extended with some mock APIs allowing testing basic Enso Cloud functionality.
|
||||||
|
See its README for instructions on how to run it.
|
||||||
|
@ -59,6 +59,8 @@ import project.Data.XML.XML_Spec
|
|||||||
|
|
||||||
import project.Data.Vector.Slicing_Helpers_Spec
|
import project.Data.Vector.Slicing_Helpers_Spec
|
||||||
|
|
||||||
|
import project.Network.Enso_Cloud.Enso_Cloud_Spec
|
||||||
|
|
||||||
import project.Network.Http.Header_Spec as Http_Header_Spec
|
import project.Network.Http.Header_Spec as Http_Header_Spec
|
||||||
import project.Network.Http.Request_Spec as Http_Request_Spec
|
import project.Network.Http.Request_Spec as Http_Request_Spec
|
||||||
import project.Network.Http_Spec
|
import project.Network.Http_Spec
|
||||||
@ -105,6 +107,7 @@ main = Test_Suite.run_main <|
|
|||||||
Http_Header_Spec.spec
|
Http_Header_Spec.spec
|
||||||
Http_Request_Spec.spec
|
Http_Request_Spec.spec
|
||||||
Http_Spec.spec
|
Http_Spec.spec
|
||||||
|
Enso_Cloud_Spec.spec
|
||||||
Import_Loop_Spec.spec
|
Import_Loop_Spec.spec
|
||||||
Interval_Spec.spec
|
Interval_Spec.spec
|
||||||
Java_Interop_Spec.spec
|
Java_Interop_Spec.spec
|
||||||
|
117
test/Tests/src/Network/Enso_Cloud/Enso_Cloud_Spec.enso
Normal file
117
test/Tests/src/Network/Enso_Cloud/Enso_Cloud_Spec.enso
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
from Standard.Base import all
|
||||||
|
import Standard.Base.Data.Enso_Cloud.Utils as Cloud_Utils
|
||||||
|
import Standard.Base.Errors.Common.No_Such_Conversion
|
||||||
|
import Standard.Base.Errors.Illegal_Argument.Illegal_Argument
|
||||||
|
import Standard.Base.Network.HTTP.HTTP_Error.HTTP_Error
|
||||||
|
|
||||||
|
from Standard.Test import Test, Test_Suite
|
||||||
|
import Standard.Test.Test_Environment
|
||||||
|
import Standard.Test.Extensions
|
||||||
|
|
||||||
|
polyglot java import org.enso.base.enso_cloud.AuthenticationProvider
|
||||||
|
|
||||||
|
## Resets the user token, to avoid cached token from other tests interfering.
|
||||||
|
reset_token =
|
||||||
|
AuthenticationProvider.setToken Nothing
|
||||||
|
|
||||||
|
spec =
|
||||||
|
## To run this test locally:
|
||||||
|
$ sbt 'http-test-helper/run localhost 8080'
|
||||||
|
$ export ENSO_HTTP_TEST_HTTPBIN_URL=http://localhost:8080/
|
||||||
|
base_url = Environment.get "ENSO_HTTP_TEST_HTTPBIN_URL"
|
||||||
|
pending_has_url = if base_url != Nothing then Nothing else
|
||||||
|
"The Cloud mock tests only run when the `ENSO_HTTP_TEST_HTTPBIN_URL` environment variable is set to URL of the http-test-helper server"
|
||||||
|
enso_cloud_url = base_url.if_not_nothing <|
|
||||||
|
with_slash = if base_url.ends_with "/" then base_url else base_url + "/"
|
||||||
|
with_slash + "enso-cloud-mock/"
|
||||||
|
tmp_cred_file = File.create_temporary_file "enso-test-credentials" ".txt"
|
||||||
|
test_token = "TEST-ENSO-TOKEN-caffee"
|
||||||
|
test_token.write tmp_cred_file
|
||||||
|
|
||||||
|
## This helper method is needed, because of the bug https://github.com/enso-org/enso/issues/7117
|
||||||
|
If the bug is fixed, we could move the overrides to the top-level and not have to re-initialize them.
|
||||||
|
with_mock_environment ~action =
|
||||||
|
Test_Environment.unsafe_with_environment_override "ENSO_CLOUD_API_URI" enso_cloud_url <|
|
||||||
|
Test_Environment.unsafe_with_environment_override "ENSO_CLOUD_CREDENTIALS_FILE" tmp_cred_file.absolute.normalize.path <|
|
||||||
|
action
|
||||||
|
with_mock_environment <|
|
||||||
|
reset_token
|
||||||
|
Test.group "Enso Cloud Basic Utils" pending=pending_has_url <|
|
||||||
|
Test.specify "will report Not_Logged_In if no credentials file is found" <|
|
||||||
|
non_existent_file = (enso_project.data / "nonexistent-file") . absolute . normalize
|
||||||
|
non_existent_file.exists.should_be_false
|
||||||
|
|
||||||
|
Test_Environment.unsafe_with_environment_override "ENSO_CLOUD_CREDENTIALS_FILE" non_existent_file.path <|
|
||||||
|
# This test has to run before any other Cloud access, otherwise the token may already be cached.
|
||||||
|
Cloud_Utils.authorization_header.should_fail_with Cloud_Utils.Not_Logged_In
|
||||||
|
|
||||||
|
Test.specify "should be able to get the cloud URL from environment" <|
|
||||||
|
api_url = Cloud_Utils.cloud_root_uri
|
||||||
|
api_url.should_equal enso_cloud_url
|
||||||
|
|
||||||
|
Test.specify "should be able to read the authorization token" <|
|
||||||
|
Cloud_Utils.authorization_header.to_display_text.should_equal "Authorization: Bearer "+test_token
|
||||||
|
|
||||||
|
Test.group "Enso_User" <|
|
||||||
|
Test.specify "is correctly parsed from JSON" <|
|
||||||
|
json = Json.parse """
|
||||||
|
{
|
||||||
|
"id": "organization-27xJM00p8jWoL2qByTo6tQfciWC",
|
||||||
|
"name": "Parsed user",
|
||||||
|
"email": "enso-parse-test@example.com",
|
||||||
|
"isEnabled": true,
|
||||||
|
"rootDirectoryId": "directory-27xJM00p8jWoL2qByTo6tQfciWC"
|
||||||
|
}
|
||||||
|
parsed_user = Enso_User.from json
|
||||||
|
parsed_user.id.should_equal "organization-27xJM00p8jWoL2qByTo6tQfciWC"
|
||||||
|
parsed_user.name.should_equal "Parsed user"
|
||||||
|
parsed_user.email.should_equal "enso-parse-test@example.com"
|
||||||
|
parsed_user.is_enabled.should_be_true
|
||||||
|
|
||||||
|
# TODO separate Enso_File tests could test that this is a valid directory
|
||||||
|
home = parsed_user.home
|
||||||
|
home.is_directory.should_be_true
|
||||||
|
|
||||||
|
invalid_json = Json.parse "{}"
|
||||||
|
Enso_User.from invalid_json . should_fail_with Illegal_Argument
|
||||||
|
Test.expect_panic No_Such_Conversion (Enso_User.from (Json.parse "[]"))
|
||||||
|
|
||||||
|
# These tests should be kept in sync with tools/http-test-helper/src/main/java/org/enso/shttp/?
|
||||||
|
Test.specify "current user can be fetched from mock API" <|
|
||||||
|
current = Enso_User.current
|
||||||
|
current.id.should_equal "organization-27xJM00p8jWoL2qByTo6tQfciWC"
|
||||||
|
current.name.should_equal "My test User 1"
|
||||||
|
current.email.should_equal "enso-test-user-1@example.com"
|
||||||
|
current.is_enabled.should_be_true
|
||||||
|
|
||||||
|
Test.specify "user list can be fetched from mock API" <|
|
||||||
|
users = Enso_User.list
|
||||||
|
|
||||||
|
users.length.should_equal 2
|
||||||
|
users.at 0 . name . should_equal "My test User 1"
|
||||||
|
users.at 1 . name . should_equal "My test User 2"
|
||||||
|
users.at 1 . is_enabled . should_be_false
|
||||||
|
|
||||||
|
Test.specify "will fail if the user is not logged in" <|
|
||||||
|
non_existent_file = (enso_project.data / "nonexistent-file") . absolute . normalize
|
||||||
|
non_existent_file.exists.should_be_false
|
||||||
|
r = Test_Environment.unsafe_with_environment_override "ENSO_CLOUD_CREDENTIALS_FILE" non_existent_file.path <|
|
||||||
|
reset_token
|
||||||
|
Enso_User.current
|
||||||
|
r.should_fail_with Cloud_Utils.Not_Logged_In
|
||||||
|
|
||||||
|
Test.specify "will fail if the token is invalid" <|
|
||||||
|
invalid_token_file = File.create_temporary_file "enso-test-credentials" "-invalid.txt"
|
||||||
|
"invalid-token".write invalid_token_file . should_succeed
|
||||||
|
reset_token
|
||||||
|
r = Test_Environment.unsafe_with_environment_override "ENSO_CLOUD_CREDENTIALS_FILE" invalid_token_file.absolute.normalize.path <|
|
||||||
|
Enso_User.current
|
||||||
|
r.should_fail_with HTTP_Error
|
||||||
|
r.catch.should_be_a HTTP_Error.Status_Error
|
||||||
|
r.catch.status_code.code . should_equal 403
|
||||||
|
|
||||||
|
# Ensure the token is reset after the last test, so that any other tests will again use the correct one.
|
||||||
|
reset_token
|
||||||
|
|
||||||
|
|
||||||
|
main = Test_Suite.run_main spec
|
@ -27,7 +27,7 @@ type Bad_To_Json
|
|||||||
|
|
||||||
spec =
|
spec =
|
||||||
## To run this test locally:
|
## To run this test locally:
|
||||||
$ sbt 'simple-httpbin/run localhost 8080'
|
$ sbt 'http-test-helper/run localhost 8080'
|
||||||
$ export ENSO_HTTP_TEST_HTTPBIN_URL=http://localhost:8080/
|
$ export ENSO_HTTP_TEST_HTTPBIN_URL=http://localhost:8080/
|
||||||
base_url = Environment.get "ENSO_HTTP_TEST_HTTPBIN_URL"
|
base_url = Environment.get "ENSO_HTTP_TEST_HTTPBIN_URL"
|
||||||
pending_has_url = if base_url != Nothing then Nothing else
|
pending_has_url = if base_url != Nothing then Nothing else
|
||||||
@ -452,7 +452,7 @@ spec =
|
|||||||
|
|
||||||
Test.specify "Multiple content types in the header list are respected" <|
|
Test.specify "Multiple content types in the header list are respected" <|
|
||||||
response = Data.post url_post (Request_Body.Text '{"a": "asdf", "b": 123}') headers=[Header.content_type "application/json", Header.content_type "text/plain"]
|
response = Data.post url_post (Request_Body.Text '{"a": "asdf", "b": 123}') headers=[Header.content_type "application/json", Header.content_type "text/plain"]
|
||||||
## Our simple-httpbin server gets 2 Content-Type headers and merges them in the response.
|
## Our http-test-helper gets 2 Content-Type headers and merges them in the response.
|
||||||
How this is interpreted in practice depends on the server.
|
How this is interpreted in practice depends on the server.
|
||||||
expected_response = Json.parse <| '''
|
expected_response = Json.parse <| '''
|
||||||
{
|
{
|
||||||
|
@ -7,7 +7,7 @@ import Standard.Test.Extensions
|
|||||||
|
|
||||||
spec =
|
spec =
|
||||||
## To run this test locally:
|
## To run this test locally:
|
||||||
$ sbt 'simple-httpbin/run localhost 8080'
|
$ sbt 'http-test-helper/run localhost 8080'
|
||||||
$ export ENSO_HTTP_TEST_HTTPBIN_URL=http://localhost:8080/
|
$ export ENSO_HTTP_TEST_HTTPBIN_URL=http://localhost:8080/
|
||||||
base_url = case Environment.get "ENSO_HTTP_TEST_HTTPBIN_URL" of
|
base_url = case Environment.get "ENSO_HTTP_TEST_HTTPBIN_URL" of
|
||||||
Nothing -> Nothing
|
Nothing -> Nothing
|
||||||
@ -129,7 +129,7 @@ spec =
|
|||||||
Test.specify "will not convert back to URI if secrets are present in the query arguments" pending="TODO testing secrets is for later" <|
|
Test.specify "will not convert back to URI if secrets are present in the query arguments" pending="TODO testing secrets is for later" <|
|
||||||
Error.throw "TODO: secrets tests"
|
Error.throw "TODO: secrets tests"
|
||||||
|
|
||||||
# We rely on the simple-httpbin server for these tests, to ensure that the encoding is indeed correctly interpreted by a real-life server:
|
# We rely on the http-test-helper for these tests, to ensure that the encoding is indeed correctly interpreted by a real-life server:
|
||||||
Test.specify "should correctly handle various characters within the key and value of arguments" pending=pending_has_url <|
|
Test.specify "should correctly handle various characters within the key and value of arguments" pending=pending_has_url <|
|
||||||
base_uri = URI.parse base_url+"get"
|
base_uri = URI.parse base_url+"get"
|
||||||
|
|
||||||
|
19
tools/http-test-helper/README.md
Normal file
19
tools/http-test-helper/README.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# HTTP Test Helper
|
||||||
|
|
||||||
|
A simple HTTP Request/Response clone of [httpbin](http://httpbin.org) for
|
||||||
|
testing purposes, extended with additional functionality allowing for testing
|
||||||
|
Enso Cloud features.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
It can be compiled like any other SBT project i.e.
|
||||||
|
|
||||||
|
```
|
||||||
|
sbt> http-test-helper/compile
|
||||||
|
```
|
||||||
|
|
||||||
|
To run, simply invoke the `main` method with the appropriate hostname and port:
|
||||||
|
|
||||||
|
```
|
||||||
|
sbt> http-test-helper/run localhost 8080
|
||||||
|
```
|
@ -12,7 +12,7 @@ public class BasicAuthTestHandler extends SimpleHttpHandler {
|
|||||||
private final String password = "my secret password: 1234@#; ść + \uD83D\uDE0E";
|
private final String password = "my secret password: 1234@#; ść + \uD83D\uDE0E";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void doHandle(HttpExchange exchange) throws IOException {
|
protected void doHandle(HttpExchange exchange) throws IOException {
|
||||||
List<String> authHeaders = exchange.getRequestHeaders().get("Authorization");
|
List<String> authHeaders = exchange.getRequestHeaders().get("Authorization");
|
||||||
if (authHeaders == null || authHeaders.isEmpty()) {
|
if (authHeaders == null || authHeaders.isEmpty()) {
|
||||||
sendResponse(401, "Not authorized.", exchange);
|
sendResponse(401, "Not authorized.", exchange);
|
@ -5,7 +5,7 @@ import java.io.IOException;
|
|||||||
|
|
||||||
public class CrashingTestHandler extends SimpleHttpHandler {
|
public class CrashingTestHandler extends SimpleHttpHandler {
|
||||||
@Override
|
@Override
|
||||||
public void doHandle(HttpExchange exchange) throws IOException {
|
protected void doHandle(HttpExchange exchange) throws IOException {
|
||||||
// This exception will be logged by SimpleHttpHandler, but that's OK - let's know that this
|
// This exception will be logged by SimpleHttpHandler, but that's OK - let's know that this
|
||||||
// crash is happening.
|
// crash is happening.
|
||||||
throw new RuntimeException("This handler crashes on purpose.");
|
throw new RuntimeException("This handler crashes on purpose.");
|
@ -10,15 +10,16 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
import org.enso.shttp.cloud_mock.CloudRoot;
|
||||||
import sun.misc.Signal;
|
import sun.misc.Signal;
|
||||||
import sun.misc.SignalHandler;
|
import sun.misc.SignalHandler;
|
||||||
|
|
||||||
public class SimpleHTTPBin {
|
public class HTTPTestHelperServer {
|
||||||
|
|
||||||
private final HttpServer server;
|
private final HttpServer server;
|
||||||
private final State state;
|
private final State state;
|
||||||
|
|
||||||
public SimpleHTTPBin(String hostname, int port) throws IOException {
|
public HTTPTestHelperServer(String hostname, int port) throws IOException {
|
||||||
InetSocketAddress address = new InetSocketAddress(hostname, port);
|
InetSocketAddress address = new InetSocketAddress(hostname, port);
|
||||||
server = HttpServer.create(address, 0);
|
server = HttpServer.create(address, 0);
|
||||||
server.setExecutor(null);
|
server.setExecutor(null);
|
||||||
@ -52,18 +53,18 @@ public class SimpleHTTPBin {
|
|||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
if (args.length != 2) {
|
if (args.length != 2) {
|
||||||
System.err.println("Usage: SimpleHTTPBin <host> <port>");
|
System.err.println("Usage: http-test-helper <host> <port>");
|
||||||
System.exit(1);
|
System.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
String host = args[0];
|
String host = args[0];
|
||||||
SimpleHTTPBin server = null;
|
HTTPTestHelperServer server = null;
|
||||||
try {
|
try {
|
||||||
int port = Integer.valueOf(args[1]);
|
int port = Integer.valueOf(args[1]);
|
||||||
server = new SimpleHTTPBin(host, port);
|
server = new HTTPTestHelperServer(host, port);
|
||||||
setupEndpoints(server);
|
setupEndpoints(server);
|
||||||
|
|
||||||
final SimpleHTTPBin server1 = server;
|
final HTTPTestHelperServer server1 = server;
|
||||||
SignalHandler stopServerHandler =
|
SignalHandler stopServerHandler =
|
||||||
(Signal sig) -> {
|
(Signal sig) -> {
|
||||||
System.out.println("Stopping server... (interrupt)");
|
System.out.println("Stopping server... (interrupt)");
|
||||||
@ -98,7 +99,7 @@ public class SimpleHTTPBin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void setupEndpoints(SimpleHTTPBin server) throws URISyntaxException {
|
private static void setupEndpoints(HTTPTestHelperServer server) throws URISyntaxException {
|
||||||
for (HttpMethod method : HttpMethod.values()) {
|
for (HttpMethod method : HttpMethod.values()) {
|
||||||
String path = "/" + method.toString().toLowerCase();
|
String path = "/" + method.toString().toLowerCase();
|
||||||
server.addHandler(path, new TestHandler(method));
|
server.addHandler(path, new TestHandler(method));
|
||||||
@ -108,12 +109,19 @@ public class SimpleHTTPBin {
|
|||||||
server.addHandler("/test_token_auth", new TokenAuthTestHandler());
|
server.addHandler("/test_token_auth", new TokenAuthTestHandler());
|
||||||
server.addHandler("/test_basic_auth", new BasicAuthTestHandler());
|
server.addHandler("/test_basic_auth", new BasicAuthTestHandler());
|
||||||
server.addHandler("/crash", new CrashingTestHandler());
|
server.addHandler("/crash", new CrashingTestHandler());
|
||||||
|
CloudRoot cloudRoot = new CloudRoot();
|
||||||
|
server.addHandler(cloudRoot.prefix, cloudRoot);
|
||||||
setupFileServer(server);
|
setupFileServer(server);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void setupFileServer(SimpleHTTPBin server) throws URISyntaxException {
|
private static void setupFileServer(HTTPTestHelperServer server) throws URISyntaxException {
|
||||||
Path myRuntimeJar =
|
Path myRuntimeJar =
|
||||||
Path.of(SimpleHTTPBin.class.getProtectionDomain().getCodeSource().getLocation().toURI())
|
Path.of(
|
||||||
|
HTTPTestHelperServer.class
|
||||||
|
.getProtectionDomain()
|
||||||
|
.getCodeSource()
|
||||||
|
.getLocation()
|
||||||
|
.toURI())
|
||||||
.toAbsolutePath();
|
.toAbsolutePath();
|
||||||
Path projectRoot = findProjectRoot(myRuntimeJar);
|
Path projectRoot = findProjectRoot(myRuntimeJar);
|
||||||
Path testFilesRoot = projectRoot.resolve(pathToWWW);
|
Path testFilesRoot = projectRoot.resolve(pathToWWW);
|
||||||
@ -134,7 +142,7 @@ public class SimpleHTTPBin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String pathToWWW = "tools/simple-httpbin/www-files";
|
private static final String pathToWWW = "tools/http-test-helper/www-files";
|
||||||
|
|
||||||
private static boolean looksLikeProjectRoot(Path path) {
|
private static boolean looksLikeProjectRoot(Path path) {
|
||||||
return Stream.of("build.sbt", "tools", "project", pathToWWW)
|
return Stream.of("build.sbt", "tools", "project", pathToWWW)
|
@ -4,11 +4,13 @@ import com.sun.net.httpserver.HttpExchange;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class TokenAuthTestHandler extends SimpleHttpHandler {
|
public abstract class HandlerWithTokenAuth extends SimpleHttpHandler {
|
||||||
private final String secretToken = "deadbeef-coffee-1234";
|
protected abstract String getSecretToken();
|
||||||
|
|
||||||
|
protected abstract void handleAuthorized(HttpExchange exchange) throws IOException;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void doHandle(HttpExchange exchange) throws IOException {
|
protected void doHandle(HttpExchange exchange) throws IOException {
|
||||||
List<String> authHeaders = exchange.getRequestHeaders().get("Authorization");
|
List<String> authHeaders = exchange.getRequestHeaders().get("Authorization");
|
||||||
if (authHeaders == null || authHeaders.isEmpty()) {
|
if (authHeaders == null || authHeaders.isEmpty()) {
|
||||||
sendResponse(401, "Not authorized.", exchange);
|
sendResponse(401, "Not authorized.", exchange);
|
||||||
@ -26,12 +28,12 @@ public class TokenAuthTestHandler extends SimpleHttpHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String providedToken = authHeader.substring(prefix.length());
|
String providedToken = authHeader.substring(prefix.length());
|
||||||
boolean authorized = providedToken.equals(secretToken);
|
boolean authorized = providedToken.equals(getSecretToken());
|
||||||
if (!authorized) {
|
if (!authorized) {
|
||||||
sendResponse(403, "Invalid token.", exchange);
|
sendResponse(403, "Invalid token.", exchange);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendResponse(200, "Authorization successful.", exchange);
|
handleAuthorized(exchange);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -7,7 +7,7 @@ import org.apache.http.client.utils.URIBuilder;
|
|||||||
|
|
||||||
public class HeaderTestHandler extends SimpleHttpHandler {
|
public class HeaderTestHandler extends SimpleHttpHandler {
|
||||||
@Override
|
@Override
|
||||||
public void doHandle(HttpExchange exchange) throws IOException {
|
protected void doHandle(HttpExchange exchange) throws IOException {
|
||||||
URI uri = exchange.getRequestURI();
|
URI uri = exchange.getRequestURI();
|
||||||
URIBuilder builder = new URIBuilder(uri);
|
URIBuilder builder = new URIBuilder(uri);
|
||||||
try {
|
try {
|
@ -28,7 +28,7 @@ public abstract class SimpleHttpHandler implements HttpHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract void doHandle(HttpExchange exchange) throws IOException;
|
protected abstract void doHandle(HttpExchange exchange) throws IOException;
|
||||||
|
|
||||||
protected final void sendResponse(int code, String message, HttpExchange exchange)
|
protected final void sendResponse(int code, String message, HttpExchange exchange)
|
||||||
throws IOException {
|
throws IOException {
|
@ -27,7 +27,7 @@ public class TestHandler extends SimpleHttpHandler {
|
|||||||
this.expectedMethod = expectedMethod;
|
this.expectedMethod = expectedMethod;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void doHandle(HttpExchange exchange) throws IOException {
|
protected void doHandle(HttpExchange exchange) throws IOException {
|
||||||
boolean first = true;
|
boolean first = true;
|
||||||
String contentType = null;
|
String contentType = null;
|
||||||
String textEncoding = "UTF-8";
|
String textEncoding = "UTF-8";
|
@ -0,0 +1,17 @@
|
|||||||
|
package org.enso.shttp;
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public class TokenAuthTestHandler extends HandlerWithTokenAuth {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getSecretToken() {
|
||||||
|
return "deadbeef-coffee-1234";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void handleAuthorized(HttpExchange exchange) throws IOException {
|
||||||
|
sendResponse(200, "Authorization successful.", exchange);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
package org.enso.shttp.cloud_mock;
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public interface CloudHandler {
|
||||||
|
boolean canHandle(String subPath);
|
||||||
|
|
||||||
|
void handleCloudAPI(CloudExchange exchange) throws IOException;
|
||||||
|
|
||||||
|
interface CloudExchange {
|
||||||
|
HttpExchange getHttpExchange();
|
||||||
|
|
||||||
|
String subPath();
|
||||||
|
|
||||||
|
void sendResponse(int code, String response) throws IOException;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,57 @@
|
|||||||
|
package org.enso.shttp.cloud_mock;
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import org.enso.shttp.HandlerWithTokenAuth;
|
||||||
|
|
||||||
|
public class CloudRoot extends HandlerWithTokenAuth {
|
||||||
|
public final String prefix = "/enso-cloud-mock/";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getSecretToken() {
|
||||||
|
return "TEST-ENSO-TOKEN-caffee";
|
||||||
|
}
|
||||||
|
|
||||||
|
private final CloudHandler[] handlers = new CloudHandler[] {new UsersHandler()};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void handleAuthorized(HttpExchange exchange) throws IOException {
|
||||||
|
URI uri = exchange.getRequestURI();
|
||||||
|
String path = uri.getPath();
|
||||||
|
int prefixStart = path.indexOf(prefix);
|
||||||
|
if (prefixStart == -1) {
|
||||||
|
sendResponse(400, "Invalid URI.", exchange);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String subPath = path.substring(prefixStart + prefix.length());
|
||||||
|
for (CloudHandler handler : handlers) {
|
||||||
|
if (handler.canHandle(subPath)) {
|
||||||
|
handler.handleCloudAPI(wrapExchange(subPath, exchange));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendResponse(404, "No handler found for: " + subPath, exchange);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CloudHandler.CloudExchange wrapExchange(String subPath, HttpExchange exchange) {
|
||||||
|
return new CloudHandler.CloudExchange() {
|
||||||
|
@Override
|
||||||
|
public HttpExchange getHttpExchange() {
|
||||||
|
return exchange;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String subPath() {
|
||||||
|
return subPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendResponse(int code, String response) throws IOException {
|
||||||
|
CloudRoot.this.sendResponse(code, response, exchange);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,65 @@
|
|||||||
|
package org.enso.shttp.cloud_mock;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public class UsersHandler implements CloudHandler {
|
||||||
|
|
||||||
|
private static final String USERS = "users";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean canHandle(String subPath) {
|
||||||
|
return subPath.startsWith(USERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleCloudAPI(CloudExchange exchange) throws IOException {
|
||||||
|
String part = exchange.subPath().substring(USERS.length());
|
||||||
|
switch (part) {
|
||||||
|
case "/me" -> sendCurrentUser(exchange);
|
||||||
|
case "" -> sendUserList(exchange);
|
||||||
|
default -> {
|
||||||
|
exchange.sendResponse(404, "No handler found for: " + part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendCurrentUser(CloudExchange exchange) throws IOException {
|
||||||
|
exchange.sendResponse(200, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendUserList(CloudExchange exchange) throws IOException {
|
||||||
|
String response =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"users": [
|
||||||
|
%s,
|
||||||
|
%s
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
.formatted(currentUser, otherUser);
|
||||||
|
exchange.sendResponse(200, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final String currentUser =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"id": "organization-27xJM00p8jWoL2qByTo6tQfciWC",
|
||||||
|
"name": "My test User 1",
|
||||||
|
"email": "enso-test-user-1@example.com",
|
||||||
|
"isEnabled": true,
|
||||||
|
"rootDirectoryId": "directory-27xJM00p8jWoL2qByTo6tQfciWC"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final String otherUser =
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"id": "organization-44AAA00A8AAAA2AAAAA6AAAAAAA",
|
||||||
|
"name": "My test User 2",
|
||||||
|
"email": "enso-test-user-2@example.com",
|
||||||
|
"isEnabled": false,
|
||||||
|
"rootDirectoryId": "directory-44AAA00A8AAAA2AAAAA6AAAAAAA"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
@ -1,18 +0,0 @@
|
|||||||
# Simple HTTPBin
|
|
||||||
|
|
||||||
A simple HTTP Request/Response clone of [httpbin](http://httpbin.org) for
|
|
||||||
testing purposes.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Simple HTTPBin can be compiled like any other SBT project i.e.
|
|
||||||
|
|
||||||
```
|
|
||||||
sbt> simple-httpbin/compile
|
|
||||||
```
|
|
||||||
|
|
||||||
To run, simply invoke the `main` method with the appropriate hostname and port:
|
|
||||||
|
|
||||||
```
|
|
||||||
sbt> simple-httpbin/run localhost 8080
|
|
||||||
```
|
|
Loading…
Reference in New Issue
Block a user