diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 811c18b..b96e9a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,21 +5,32 @@ on: [pull_request] jobs: test: name: "Test" - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 + # See https://hexdocs.pm/elixir/compatibility-and-deprecations.html#compatibility-between-elixir-and-erlang-otp + strategy: + matrix: + include: + - pair: + otp: "23.3.4.20" + elixir: "1.14.5" + - pair: + otp: "27.0" + elixir: "1.17.1" + lint: lint env: MIX_ENV: test steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup BEAM uses: erlef/setup-beam@v1 with: - otp-version: "25.2" - elixir-version: "1.14.2" + otp-version: ${{matrix.pair.otp}} + elixir-version: ${{matrix.pair.elixir}} - name: Fetch Hex Cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: hex-cache with: path: | @@ -29,17 +40,25 @@ jobs: restore-keys: | ${{ runner.os }}-mix- + - name: Check Code Format + run: mix format --check-formatted + if: ${{matrix.lint}} + - name: Run Tests run: | mix deps.get mix test - name: Publish Results - uses: EnricoMi/publish-unit-test-result-action@v2 - if: ${{ success() || failure() }} + uses: dorny/test-reporter@v1 + if: ${{ failure() }} with: - comment_mode: "off" - junit_files: _build/test/lib/oapi_generator/*.xml + fail-on-error: "false" + list-suites: failed + list-tests: failed + name: Results + path: _build/test/lib/oapi_generator/*.xml + reporter: java-junit diff: name: "Generate Diff" @@ -49,4 +68,4 @@ jobs: env: GH_TOKEN: ${{ secrets.DIFF_TOKEN }} run: | - gh workflow run generate.yml --repo aj-foster/open-api-diffs --field ref=$GITHUB_SHA + gh workflow run generate.yml --repo aj-foster/open-api-diffs --field ref=$GITHUB_HEAD_REF diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7416a19..bd97097 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Release on: push: tags: - - '*' + - "*" jobs: publish: @@ -16,8 +16,8 @@ jobs: - name: Setup BEAM uses: erlef/setup-beam@v1 with: - otp-version: "25.2" - elixir-version: "1.14.2" + otp-version: "27.0" + elixir-version: "1.17.1" - name: Publish Package run: | diff --git a/.gitignore b/.gitignore index 2948407..ef34e49 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ oapi_generator-*.tar # Test output files /example* + +# Test specifications +/specs diff --git a/.tool-versions b/.tool-versions index 229bbe0..6fe0c3d 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -elixir 1.16.2-otp-26 -erlang 26.2.2 +elixir 1.17.1-otp-27 +erlang 27.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index afa11ab..dcbf0cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -_Nothing yet._ +* **Breaking**: Snake case normalization (ex. function names) now correctly segments numbers. + For example, an operation `v2example` is now output as `v2_example`. + This may be a breaking change for clients with numbers in operation IDs. + +* **Add**: New configuration option `naming.field_casing` to choose between `:camel` case, `:snake` case, or performing no normalization (`nil`, the default). + Using this option may be necessary for API descriptions that include non-normalized field names (for example, fields that begin with a number or symbol). + Setting this configuration would be a breaking change for any clients based on API descriptions that have inconsistent field casing. ### 0.1.1 (2024-05-17) diff --git a/guides/configuration.md b/guides/configuration.md index 707d327..e852ccd 100644 --- a/guides/configuration.md +++ b/guides/configuration.md @@ -244,6 +244,10 @@ Remember that all configuration values must be contained within a profile. Defaults to `Operations`. See `OpenAPI.Processor.Naming.operation_modules/2` for more information. +* `naming.field_casing`: Either `:camel`, `:snake`, or `nil` (default) to output schema field names as `camelCase`, `snake_case`, or leave the fields name as-is from the API description. + Changing the field casing is likely to be a breaking change for clients, unless the API description consistently uses the same casing. + Setting this field may be necessary if field names require normalization (ex. if a field begins with a number). + * `naming.group`: List of module namespaces to use while naming operations and schemas. Defaults to an empty list of modules. See `OpenAPI.Processor.Naming.group_schema/2` for more information. diff --git a/lib/open_api/processor.ex b/lib/open_api/processor.ex index 6595c10..0795fb6 100644 --- a/lib/open_api/processor.ex +++ b/lib/open_api/processor.ex @@ -55,15 +55,34 @@ defmodule OpenAPI.Processor do quote do @behaviour OpenAPI.Processor + @impl OpenAPI.Processor defdelegate ignore_operation?(state, operation), to: OpenAPI.Processor + + @impl OpenAPI.Processor defdelegate ignore_schema?(state, schema), to: OpenAPI.Processor + + @impl OpenAPI.Processor defdelegate operation_docstring(state, operation_spec, params), to: OpenAPI.Processor + + @impl OpenAPI.Processor defdelegate operation_function_name(state, operation_spec), to: OpenAPI.Processor + + @impl OpenAPI.Processor defdelegate operation_module_names(state, operation_spec), to: OpenAPI.Processor + + @impl OpenAPI.Processor defdelegate operation_request_body(state, operation_spec), to: OpenAPI.Processor + + @impl OpenAPI.Processor defdelegate operation_request_method(state, operation_spec), to: OpenAPI.Processor + + @impl OpenAPI.Processor defdelegate operation_response_body(state, operation_spec), to: OpenAPI.Processor + + @impl OpenAPI.Processor defdelegate schema_format(state, schema), to: OpenAPI.Processor + + @impl OpenAPI.Processor defdelegate schema_module_and_type(state, schema), to: OpenAPI.Processor defoverridable ignore_operation?: 2, @@ -438,6 +457,13 @@ defmodule OpenAPI.Processor do end end) + field_name = + case config(state, :field_casing) do + :camel -> OpenAPI.Processor.Naming.normalize_identifier(field_name, :lower_camel) + :snake -> OpenAPI.Processor.Naming.normalize_identifier(field_name, :snake) + _else -> field_name + end + field = %Field{ name: field_name, nullable: nullable?, @@ -495,4 +521,13 @@ defmodule OpenAPI.Processor do State.put_schema(state, schema.ref, schema) end end + + @spec config(OpenAPI.Processor.State.t(), atom) :: term + @spec config(OpenAPI.Processor.State.t(), atom, term) :: term + defp config(state, key, default \\ nil) do + %OpenAPI.Processor.State{profile: profile} = state + + Application.get_env(:oapi_generator, profile, []) + |> Keyword.get(key, default) + end end diff --git a/lib/open_api/processor/naming.ex b/lib/open_api/processor/naming.ex index 8157180..9def2fc 100644 --- a/lib/open_api/processor/naming.ex +++ b/lib/open_api/processor/naming.ex @@ -501,8 +501,11 @@ defmodule OpenAPI.Processor.Naming do iex> normalize_identifier("openAPISpec", :camel) "OpenAPISpec" + iex> normalize_identifier("get-/customer/purchases/{date}_byId", :lower_camel) + "getCustomerPurchasesDateById" + """ - @spec normalize_identifier(String.t(), :camel | :snake) :: String.t() + @spec normalize_identifier(String.t(), :camel | :lower_camel | :snake) :: String.t() def normalize_identifier(input, casing \\ :snake) def normalize_identifier(input, :camel) do @@ -518,6 +521,21 @@ defmodule OpenAPI.Processor.Naming do |> Enum.join() end + def normalize_identifier(input, :lower_camel) do + [first_segment | segments] = segment_identifier(input) + + segments = + Enum.map(segments, fn segment -> + if String.match?(segment, ~r/^[A-Z]+$/) do + segment + else + String.capitalize(segment) + end + end) + + Enum.join([first_segment | segments]) + end + def normalize_identifier(input, :snake) do input |> segment_identifier() @@ -526,12 +544,17 @@ defmodule OpenAPI.Processor.Naming do @doc false def segment_identifier(input) do - input - |> String.split(~r/[^A-Za-z0-9]+|([A-Z]?[a-z0-9]+)/, include_captures: true, trim: true) + [first_segment | segments] = + String.split(input, ~r/[^A-Za-z0-9]+|([A-Z]?[a-z]+[0-9]?+)/, + include_captures: true, + trim: true + ) + + first_segment = String.replace(first_segment, ~r/^[^A-Za-z]+/, "") + + [first_segment | segments] |> Enum.map(fn segment -> - segment - |> String.replace(~r/^[^A-Za-z]+/, "") - |> String.replace(~r/[^A-Za-z0-9]+$/, "") + String.replace(segment, ~r/[^A-Za-z0-9]+$/, "") end) |> Enum.reject(&(&1 == "")) end diff --git a/lib/open_api/renderer.ex b/lib/open_api/renderer.ex index 7324223..bfcce49 100644 --- a/lib/open_api/renderer.ex +++ b/lib/open_api/renderer.ex @@ -30,21 +30,52 @@ defmodule OpenAPI.Renderer do quote do @behaviour OpenAPI.Renderer + @impl OpenAPI.Renderer defdelegate format(state, file), to: OpenAPI.Renderer + + @impl OpenAPI.Renderer defdelegate location(state, file), to: OpenAPI.Renderer + + @impl OpenAPI.Renderer defdelegate render(state, file), to: OpenAPI.Renderer + + @impl OpenAPI.Renderer defdelegate render_default_client(state, file), to: OpenAPI.Renderer + + @impl OpenAPI.Renderer defdelegate render_moduledoc(state, file), to: OpenAPI.Renderer + + @impl OpenAPI.Renderer defdelegate render_operations(state, file), to: OpenAPI.Renderer + + @impl OpenAPI.Renderer defdelegate render_operation(state, operation), to: OpenAPI.Renderer + + @impl OpenAPI.Renderer defdelegate render_operation_doc(state, operation), to: OpenAPI.Renderer + + @impl OpenAPI.Renderer defdelegate render_operation_function(state, operation), to: OpenAPI.Renderer + + @impl OpenAPI.Renderer defdelegate render_operation_spec(state, operation), to: OpenAPI.Renderer + + @impl OpenAPI.Renderer defdelegate render_schema(state, file), to: OpenAPI.Renderer + + @impl OpenAPI.Renderer defdelegate render_schema_field_function(state, schemas), to: OpenAPI.Renderer + + @impl OpenAPI.Renderer defdelegate render_schema_struct(state, schemas), to: OpenAPI.Renderer + + @impl OpenAPI.Renderer defdelegate render_schema_types(state, schemas), to: OpenAPI.Renderer + + @impl OpenAPI.Renderer defdelegate render_using(state, file), to: OpenAPI.Renderer + + @impl OpenAPI.Renderer defdelegate write(state, file), to: OpenAPI.Renderer defoverridable format: 2, diff --git a/mix.exs b/mix.exs index 0b8a251..a816804 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule OpenAPI.MixProject do [ app: :oapi_generator, version: @version, - elixir: "~> 1.13", + elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, name: "OpenAPI Generator", @@ -29,6 +29,7 @@ defmodule OpenAPI.MixProject do defp deps do [ {:ex_doc, "~> 0.29", only: :dev, runtime: false}, + {:junit_formatter, "~> 3.4", only: [:test]}, {:yaml_elixir, "~> 2.9"} ] end diff --git a/mix.lock b/mix.lock index d206ea1..31ea371 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,7 @@ %{ "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, "ex_doc": {:hex, :ex_doc, "0.32.2", "f60bbeb6ccbe75d005763e2a328e6f05e0624232f2393bc693611c2d3ae9fa0e", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "a4480305cdfe7fdfcbb77d1092c76161626d9a7aa4fb698aee745996e34602df"}, + "junit_formatter": {:hex, :junit_formatter, "3.4.0", "d0e8db6c34dab6d3c4154c3b46b21540db1109ae709d6cf99ba7e7a2ce4b1ac2", [:mix], [], "hexpm", "bb36e2ae83f1ced6ab931c4ce51dd3dbef1ef61bb4932412e173b0cfa259dacd"}, "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, diff --git a/test/open_api/processor/ignore_test.exs b/test/open_api/processor/ignore_test.exs index 627b591..26135de 100644 --- a/test/open_api/processor/ignore_test.exs +++ b/test/open_api/processor/ignore_test.exs @@ -52,7 +52,9 @@ defmodule OpenAPI.Processor.IgnoreTest do base_file_path = ["components", "schemas", "ignored_schema"] schema = %OpenAPI.Spec.Schema{schema | "$oag_base_file_path": base_file_path} - Application.put_env(:oapi_generator, @profile, ignore: ["components/schemas/ignored_schema"]) + Application.put_env(:oapi_generator, @profile, + ignore: ["components/schemas/ignored_schema"] + ) assert Ignore.ignore_schema?(state, schema) end @@ -68,7 +70,9 @@ defmodule OpenAPI.Processor.IgnoreTest do ref_path = ["components", "schemas", "ignored_schema"] schema = %OpenAPI.Spec.Schema{schema | "$oag_last_ref_path": ref_path} - Application.put_env(:oapi_generator, @profile, ignore: ["components/schemas/ignored_schema"]) + Application.put_env(:oapi_generator, @profile, + ignore: ["components/schemas/ignored_schema"] + ) assert Ignore.ignore_schema?(state, schema) end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..c5578cf 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,5 @@ +if System.get_env("CI") do + ExUnit.configure(formatters: [JUnitFormatter]) +end + ExUnit.start()