Skip to content

Commit

Permalink
Parse config from bucket URL
Browse files Browse the repository at this point in the history
  • Loading branch information
philss committed Aug 17, 2023
1 parent 90dd8f4 commit eb4f39f
Show file tree
Hide file tree
Showing 2 changed files with 171 additions and 0 deletions.
86 changes: 86 additions & 0 deletions lib/fss/s3.ex
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,90 @@ defmodule FSS.S3 do
token: System.get_env("AWS_SESSION_TOKEN")
}
end

@doc """
Parses configuration from a bucket URL.
It expects an URL in the following formats:
- `https://s3.[region].amazonaws.com/[bucket]`
- `https://[bucket].s3.[region].amazonaws.com`
- `https://my-custom-endpoint.com/[bucket]`
If the URL is not in one of these formats, then an error
is returned. For custom endpoints (services that are compatible
with AWS S3), it's possible to pass the port and use HTTP.
All the additional required configuration must be passed as
options to the `:config` option.
## Options
* `:config` - It expects a `Config.t()` or a `Keyword.t()` with the keys
representing the attributes of the `Config.t()`. By default it is `nil`,
which means that we are going to try to fetch the credentials and configuration
from the system's environment variables.
The following env vars are read:
- `AWS_ACCESS_KEY_ID`
- `AWS_SECRET_ACCESS_KEY`
- `AWS_REGION` or `AWS_DEFAULT_REGION`
- `AWS_SESSION_TOKEN`
Although you can pass the `:bucket` and `:region` as options,
they are always override by the values parsed from the URL.
"""
@spec parse_config_from_bucket_url(String.t()) :: {:ok, Config.t()} | {:error, Exception.t()}
def parse_config_from_bucket_url(bucket_url, opts \\ []) do
opts = Keyword.validate!(opts, config: nil)

uri = URI.parse(bucket_url)

case uri do
%{host: host, path: path} when is_binary(host) and (is_nil(path) or path == "/") ->
base_config =
opts
|> Keyword.fetch!(:config)
|> normalize_config!()

case String.split(host, ".") do
[bucket, "s3", region, "amazonaws", "com"] ->
{:ok, %Config{base_config | bucket: bucket, region: region}}

_other ->
{:error,
ArgumentError.exception(
"cannot extract bucket name from URL. Expected URL in the format " <>
"https://s3.[region].amazonaws.com/[bucket], got: " <>
URI.to_string(uri)
)}
end

%{host: host, path: "/" <> bucket} when is_binary(host) ->
base_config =
opts
|> Keyword.fetch!(:config)
|> normalize_config!()
|> then(fn config -> %Config{config | bucket: bucket} end)

case String.split(host, ".") do
["s3", region, "amazonaws", "com"] ->
{:ok, %Config{base_config | region: region}}

_other ->
endpoint_uri = %URI{uri | path: nil}

{:ok, %Config{base_config | endpoint: URI.to_string(endpoint_uri)}}
end

_ ->
{:error,
ArgumentError.exception(
"expected URL in the format " <>
"https://s3.[region].amazonaws.com/[bucket], got: " <>
URI.to_string(uri)
)}
end
end
end
85 changes: 85 additions & 0 deletions test/fss/s3_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,89 @@ defmodule FSS.S3Test do
end
end
end

describe "parse_config_from_bucket_url/2" do
setup do
default_config = [
secret_access_key: "my-secret",
access_key_id: "my-access"
]

[default_config: default_config]
end

test "parses a path-style AWS S3 url", %{default_config: default_config} do
assert {:ok, config} =
S3.parse_config_from_bucket_url("https://s3.us-west-2.amazonaws.com/my-bucket",
config: default_config
)

assert %Config{} = config
assert config.region == "us-west-2"
assert config.bucket == "my-bucket"
assert is_nil(config.endpoint)
end

test "parses a host-style AWS S3 url", %{default_config: default_config} do
assert {:ok, config} =
S3.parse_config_from_bucket_url("https://my-bucket-1.s3.us-west-2.amazonaws.com/",
config: default_config
)

assert %Config{} = config
assert config.region == "us-west-2"
assert config.bucket == "my-bucket-1"
assert is_nil(config.endpoint)
end

test "parses a path-style S3 compatible url", %{default_config: default_config} do
assert {:ok, config} =
S3.parse_config_from_bucket_url("https://storage.googleapis.com/my-bucket-on-gcp",
config: default_config
)

assert %Config{} = config
assert is_nil(config.region)
assert config.bucket == "my-bucket-on-gcp"
assert config.endpoint == "https://storage.googleapis.com"
end

test "parses a path-style S3 compatible url with a port", %{default_config: default_config} do
assert {:ok, config} =
S3.parse_config_from_bucket_url("http://localhost:4852/my-bucket-on-lh",
config: default_config
)

assert %Config{} = config
assert is_nil(config.region)
assert config.bucket == "my-bucket-on-lh"
assert config.endpoint == "http://localhost:4852"
end

test "cannot extract bucket from host-style S3 url", %{default_config: default_config} do
assert {:error, error} =
S3.parse_config_from_bucket_url("https://my-bucket-on-gcp.storage.googleapis.com",
config: default_config
)

message =
"cannot extract bucket name from URL. Expected URL in the format " <>
"https://s3.[region].amazonaws.com/[bucket], got: " <>
"https://my-bucket-on-gcp.storage.googleapis.com"

assert error == ArgumentError.exception(message)
end

test "cannot parse url without host", %{default_config: default_config} do
assert {:error, error} =
S3.parse_config_from_bucket_url("/my-path",
config: default_config
)

message =
"expected URL in the format https://s3.[region].amazonaws.com/[bucket], got: /my-path"

assert error == ArgumentError.exception(message)
end
end
end

0 comments on commit eb4f39f

Please sign in to comment.