diff --git a/lib/aws_codegen.ex b/lib/aws_codegen.ex index a614383..5be5a84 100644 --- a/lib/aws_codegen.ex +++ b/lib/aws_codegen.ex @@ -33,6 +33,7 @@ defmodule AWS.CodeGen do protocol: nil, signature_version: nil, service_id: nil, + shapes: %{}, signing_name: nil, target_prefix: nil end diff --git a/lib/aws_codegen/post_service.ex b/lib/aws_codegen/post_service.ex index c32e4bd..9a897c9 100644 --- a/lib/aws_codegen/post_service.ex +++ b/lib/aws_codegen/post_service.ex @@ -1,15 +1,31 @@ defmodule AWS.CodeGen.PostService do alias AWS.CodeGen.Docstring alias AWS.CodeGen.Service + alias AWS.CodeGen.Shapes + alias AWS.CodeGen.Name defmodule Action do defstruct arity: nil, docstring: nil, function_name: nil, + input: nil, + output: nil, + errors: %{}, host_prefix: nil, name: nil end + defmodule Shape do + defstruct name: nil, + type: nil, + members: [], + member: [], + enum: [], + min: nil, + required: [], + is_input: nil + end + @configuration %{ "ec2" => %{ content_type: "application/x-www-form-urlencoded", @@ -57,6 +73,7 @@ defmodule AWS.CodeGen.PostService do service = spec.api["shapes"][spec.shape_name] traits = service["traits"] actions = collect_actions(language, spec.api) + shapes = collect_shapes(language, spec.api) endpoint_prefix = traits["aws.api#service"]["endpointPrefix"] || traits["aws.api#service"]["arnNamespace"] endpoint_info = endpoints_spec["services"][endpoint_prefix] is_global = not is_nil(endpoint_info) and not Map.get(endpoint_info, "isRegionalized", true) @@ -89,6 +106,7 @@ defmodule AWS.CodeGen.PostService do language: language, module_name: spec.module_name, protocol: protocol |> to_string() |> String.replace("_", "-"), + shapes: shapes, signing_name: signing_name, signature_version: AWS.CodeGen.Util.get_signature_version(service), service_id: AWS.CodeGen.Util.get_service_id(service), @@ -137,10 +155,45 @@ defmodule AWS.CodeGen.PostService do ), function_name: AWS.CodeGen.Name.to_snake_case(operation), host_prefix: operation_spec["traits"]["smithy.api#endpoint"]["hostPrefix"], - name: String.replace(operation, ~r/com\.amazonaws\.[^#]+#/, "") + name: String.replace(operation, ~r/com\.amazonaws\.[^#]+#/, ""), + input: operation_spec["input"], + output: operation_spec["output"], + errors: operation_spec["errors"] } end) |> Enum.sort(fn a, b -> a.function_name < b.function_name end) |> Enum.uniq() end + + defp collect_shapes(_language, api_spec) do + api_spec["shapes"] + |> Enum.sort(fn {name_a, _}, {name_b, _} -> name_a < name_b end) + |> Enum.map(fn {name, shape} -> + {name, + %Shape{ + name: name, + type: shape["type"], + member: shape["member"], + members: shape["members"], + min: shape["min"], + enum: shape["enum"], + is_input: is_input?(shape) + }} + end) + |> Enum.into(%{}) + end + + defp is_input?(shape) do + if Map.has_key?(shape, "traits") do + traits = shape["traits"] + if Map.has_key?(traits, "smithy.api#input") do + true + else + false + end + else + true + end + end + end diff --git a/lib/aws_codegen/shapes.ex b/lib/aws_codegen/shapes.ex index 946f9cc..ab395d6 100644 --- a/lib/aws_codegen/shapes.ex +++ b/lib/aws_codegen/shapes.ex @@ -1,4 +1,5 @@ defmodule AWS.CodeGen.Shapes do + alias AWS.CodeGen.Name @moduledoc false def get_input_shape(operation_spec) do diff --git a/lib/aws_codegen/types.ex b/lib/aws_codegen/types.ex new file mode 100644 index 0000000..7a6c5dd --- /dev/null +++ b/lib/aws_codegen/types.ex @@ -0,0 +1,150 @@ +defmodule AWS.CodeGen.Types do + alias AWS.CodeGen.PostService.Shape + alias AWS.CodeGen.Name + + # Unfortunately, gotta patch over auto-defining types that already exist in Elixir + + def shape_to_type(:elixir, "String", _) do + "String.t()" + end + def shape_to_type(:erlang, "String", _) do + "string()" + end + + def shape_to_type(:elixir, "string", _) do + "String.t()" + end + def shape_to_type(:erlang, "string", _) do + "string()" + end + + def shape_to_type(:elixir, "Identifier", _) do + "String.t()" + end + def shape_to_type(:erlang, "Identifier", _) do + "string()" + end + + def shape_to_type(:elixir, "identifier", _) do + "String.t()" + end + def shape_to_type(:erlang, "identifier", _) do + "string()" + end + + def shape_to_type(:elixir, "XmlString" <> _rest, _) do + "String.t()" + end + def shape_to_type(:erlang, "XmlString" <> _rest, _) do + "string" + end + + def shape_to_type(:elixir, "NullablePositiveInteger", _) do + "nil | non_neg_integer()" + end + def shape_to_type(:erlang, "NullablePositiveInteger", _) do + "undefined | non_neg_integer()" + end + + def shape_to_type(_, %Shape{type: type}, _module_name) when type in ["float", "double", "long"] do + "float()" + end + + def shape_to_type(_, %Shape{type: "timestamp"}, _module_name) do + "non_neg_integer()" + end + + def shape_to_type(_, %Shape{type: "map"}, _module_name) do + "map()" + end + + def shape_to_type(_, %Shape{type: "blob"}, _module_name) do + "binary()" + end + + def shape_to_type(:elixir, %Shape{type: "string"}, _module_name) do + "String.t()" + end + def shape_to_type(:erlang, %Shape{type: "string"}, _module_name) do + "string()" + end + + def shape_to_type(_, %Shape{type: "integer"}, _module_name) do + "integer()" + end + + def shape_to_type(_, %Shape{type: "boolean"}, _module_name) do + "boolean()" + end + + def shape_to_type(_, %Shape{type: "enum"}, _module_name) do + "list(any())" + end + + def shape_to_type(_, %Shape{type: "union"}, _module_name) do + "list()" + end + + def shape_to_type(_, %Shape{type: "document"}, _module_name) do + "any()" + end + + def shape_to_type(context, shape_name, module_name, all_shapes) do + case shape_name do + "smithy.api#String" -> + "[#{shape_to_type(context.language, %Shape{type: "string"}, module_name)}]" + + "smithy.api#Integer" -> + "[#{shape_to_type(context.language, %Shape{type: "integer"}, module_name)}]" + + "smithy.api#Timestamp" -> + "[#{shape_to_type(context.language, %Shape{type: "timestamp"}, module_name)}]" + + "smithy.api#PrimitiveLong" -> + "[#{shape_to_type(context.language, %Shape{type: "long"}, module_name)}]" + + "smithy.api#Long" -> + "[#{shape_to_type(context.language, %Shape{type: "long"}, module_name)}]" + + "smithy.api#Boolean" -> + "[#{shape_to_type(context.language, %Shape{type: "boolean"}, module_name)}]" + + "smithy.api#PrimitiveBoolean" -> + "[#{shape_to_type(context.language, %Shape{type: "boolean"}, module_name)}]" + + "smithy.api#Double" -> + "[#{shape_to_type(context.language, %Shape{type: "double"}, module_name)}]" + + "smithy.api#Document" -> + "[#{shape_to_type(context.language, %Shape{type: "document"}, module_name)}]" + + "smithy.api#Unit" -> + "[]" + + _ -> + case all_shapes[shape_name] do + %Shape{type: "structure"} -> + type = "#{AWS.CodeGen.Name.to_snake_case(String.replace(shape_name, ~r/com\.amazonaws\.[^#]+#/, ""))}" + if AWS.CodeGen.Util.reserved_type(type) do + "#{String.downcase(String.replace(context.module_name, ["aws_", "AWS."], ""))}_#{type}()" + else + "#{type}()" + end + + %Shape{type: "list", member: member} -> + type = "#{shape_to_type(context, member["target"], module_name, all_shapes)}" + if AWS.CodeGen.Util.reserved_type(type) do + "list(#{String.downcase(String.replace(context.module_name, ["aws_", "AWS."], ""))}_#{type}())" + else + "list(#{type}())" + end + + nil -> + raise "Tried to reference an undefined shape for #{shape_name}" + + shape -> + shape_to_type(context.language, shape, module_name) + end + end + end +end diff --git a/lib/aws_codegen/util.ex b/lib/aws_codegen/util.ex index 9c416fa..46fb073 100644 --- a/lib/aws_codegen/util.ex +++ b/lib/aws_codegen/util.ex @@ -30,4 +30,136 @@ defmodule AWS.CodeGen.Util do service["traits"]["aws.api#service"]["sdkId"] end + def input_keys(action, context) do + shapes = context.shapes + input_shape = action.input["target"] + maybe_shape = Enum.filter(shapes, fn {name, _shape} -> input_shape == name end) + case maybe_shape do + [] -> + [] + [{_name, shape}] -> + Enum.reduce(shape.members, + [], + fn {name, %{"traits" => traits}}, acc -> + if Map.has_key?(traits, "smithy.api#required") do + [name <> " Required: true" | acc] + else + [name <> " Required: false" | acc] + end + {name, _shape}, acc -> + [name <> " Required: false" | acc] + end) + |> Enum.reverse() + end + end + + def types(context) do + Enum.reduce(context.shapes, + Map.new(), + fn {_name, shape}, acc -> + if shape.type == "structure" and not is_nil(shape.members) do + type = AWS.CodeGen.Name.to_snake_case(String.replace(shape.name, ~r/com\.amazonaws\.[^#]+#/, "")) + types = Enum.reduce(shape.members, + Map.new(), + fn {_name, shape_member}, a -> + target = shape_member["target"] + shape_member_type = AWS.CodeGen.Types.shape_to_type(context, target, context.module_name, context.shapes) + Map.put(a, is_required(context.language, shape.is_input, shape_member, target), shape_member_type) + end) + if reserved_type(type) do + Map.put(acc, "#{String.downcase(String.replace(context.module_name, "AWS.", ""))}_#{type}", types) + else + Map.put(acc, type, types) + end + else + acc + end + end) + end + + defp is_required(:elixir, is_input, shape, target) do + trimmed_name = String.replace(target, ~r/com\.amazonaws\.[^#]+#/, "") + if is_input do + if Map.has_key?(shape, "traits") do + if Map.has_key?(shape["traits"], "smithy.api#required") do + "required(\"#{trimmed_name}\")" + else + "optional(\"#{trimmed_name}\")" + end + else + "optional(\"#{trimmed_name}\")" + end + else + "\"#{trimmed_name}\"" + end + end + defp is_required(:erlang, _is_input, _shape, target) do + # There is no optional/1 | required/1 type of thing in Erlang. + # Instead we'd use := | => but let's deal with that problem later... + trimmed_name = String.replace(target, ~r/com\.amazonaws\.[^#]+#/, "") + "\"#{trimmed_name}\"" + end + + def function_argument_type(:elixir, action) do + case Map.get(action.input, "target") do + "smithy.api#Unit" -> "%{}" + type -> + "#{AWS.CodeGen.Name.to_snake_case(String.replace(type, ~r/com\.amazonaws\.[^#]+#/, ""))}()" + end + end + def function_argument_type(:erlang, action) do + case Map.get(action.input, "target") do + "smithy.api#Unit" -> "\#{}" + type -> + "#{AWS.CodeGen.Name.to_snake_case(String.replace(type, ~r/com\.amazonaws\.[^#]+#/, ""))}()" + end + end + + def return_type(action) do + case Map.get(action.output, "target") do + "smithy.api#Unit" -> "[]" + type -> + normal = "{:ok, #{AWS.CodeGen.Name.to_snake_case(String.replace(type, ~r/com\.amazonaws\.[^#]+#/, ""))}(), any()}" + errors = + if is_list(action.errors) do + Enum.map(action.errors, + fn %{"target" => error_type} -> + "{:error, #{AWS.CodeGen.Name.to_snake_case(String.replace(error_type, ~r/com\.amazonaws\.[^#]+#/, ""))}()}" + _ -> + "" + end ) + else + [] + end + Enum.join([normal, "{:error, {:unexpected_response, any()}}" | errors], " | ") + end + end + def return_type(:erlang, action) do + case Map.get(action.output, "target") do + "smithy.api#Unit" -> "[]" + type -> + normal = "{ok, #{AWS.CodeGen.Name.to_snake_case(String.replace(type, ~r/com\.amazonaws\.[^#]+#/, ""))}(), tuple()}" + errors = + if is_list(action.errors) do + Enum.map(action.errors, + fn %{"target" => error_type} -> + "{error, #{AWS.CodeGen.Name.to_snake_case(String.replace(error_type, ~r/com\.amazonaws\.[^#]+#/, ""))}(), tuple()}" + _ -> + "" + end ) + else + [] + end + Enum.join([normal, "{error, any()}" | errors], " |\n ") + end + end + + def reserved_type(type) do + if type == "node" || type == "term" do + true + else + false + end + end + end diff --git a/priv/post.erl.eex b/priv/post.erl.eex index 691b1f3..f71b3ae 100644 --- a/priv/post.erl.eex +++ b/priv/post.erl.eex @@ -8,11 +8,23 @@ -include_lib("hackney/include/hackney_lib.hrl"). +<%= for {type_name, type_fields} <- AWS.CodeGen.Util.types(context) do %> +%% Example: +%% <%= type_name %>() :: #{ +<%= Enum.map_join(type_fields, ",\n", fn {field_name, field_type} -> + ~s{%% <<#{field_name}>> => #{field_type}} +end) %> +%% } +-type <%= "#{type_name}()" %> :: #{binary() => any()}. +<% end %> + %%==================================================================== %% API %%==================================================================== <%= for action <- context.actions do %> <%= action.docstring %> +-spec <%= action.function_name %>(map(), <%= AWS.CodeGen.Util.function_argument_type(context.language, action)%>, list()) -> + <%= AWS.CodeGen.Util.return_type(context.language, action)%>. <%= action.function_name %>(Client, Input) when is_map(Client), is_map(Input) -> <%= action.function_name %>(Client, Input, []). diff --git a/priv/post.ex.eex b/priv/post.ex.eex index fc8bd43..dfe1de1 100644 --- a/priv/post.ex.eex +++ b/priv/post.ex.eex @@ -11,6 +11,19 @@ defmodule <%= context.module_name %> do alias AWS.Client alias AWS.Request + <%= for {type_name, type_fields} <- AWS.CodeGen.Util.types(context) do %> +@typedoc """ + +## Example: +<%= type_name %>() :: %{ +<%= Enum.map_join(type_fields, ",\n", fn {field_name, field_type} -> + ~s{ #{field_name} => #{field_type}} +end) %> +} +""" +@type <%= "#{type_name}()" %> :: %{String.t => any()} +<% end %> + def metadata do %{ api_version: <%= inspect(context.api_version) %>, @@ -30,6 +43,7 @@ defmodule <%= context.module_name %> do @doc """ <%= action.docstring %> """<% end %> + @spec <%= action.function_name %>(map(), <%= AWS.CodeGen.Util.function_argument_type(context.language, action)%>, list()) :: <%= AWS.CodeGen.Util.return_type(context.language, action)%> def <%= action.function_name %>(%Client{} = client, input, options \\ []) do meta = <%= if action.host_prefix do %> diff --git a/priv/rest.erl.eex b/priv/rest.erl.eex index cfa3a4d..5c6f003 100644 --- a/priv/rest.erl.eex +++ b/priv/rest.erl.eex @@ -135,7 +135,7 @@ %% Internal functions %%==================================================================== --spec proplists_take(any(), proplists:proplists(), any()) -> {any(), proplists:proplists()}. +-spec proplists_take(any(), proplists:proplist(), any()) -> {any(), proplists:proplist()}. proplists_take(Key, Proplist, Default) -> Value = proplists:get_value(Key, Proplist, Default), {Value, proplists:delete(Key, Proplist)}.