├── .formatter.exs ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── guides ├── Getting Started.md └── Phoenix Usage.md ├── lib ├── versioning.ex └── versioning │ ├── adapter.ex │ ├── adapter │ ├── date.ex │ └── semantic.ex │ ├── change.ex │ ├── changelog.ex │ ├── changelog │ ├── formatter.ex │ └── markdown.ex │ ├── controller.ex │ ├── exceptions.ex │ ├── plug.ex │ ├── schema.ex │ ├── schema │ ├── compiler.ex │ └── executer.ex │ └── view.ex ├── mix.exs ├── mix.lock └── test ├── support ├── changes.ex └── schemas.ex ├── test_helper.exs ├── versioning ├── adapters │ ├── date_test.exs │ └── semantic_test.exs ├── changelog_test.exs ├── controller_test.exs ├── formatter │ └── markdown_test.exs ├── plug_test.exs ├── schema_test.exs └── view_test.exs └── versioning_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # ElixirLS compilation files. 17 | /.elixir_ls 18 | 19 | # If the VM crashes, it generates a dump, let's ignore it too. 20 | erl_crash.dump 21 | 22 | # Also ignore archive artifacts (built via "mix archive.build"). 23 | *.ez 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | versioning-*.tar 27 | 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | 3 | elixir: 4 | - 1.7.3 5 | 6 | script: 7 | - mix test 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Versioning Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [0.3.0] - 2019-04-11 8 | 9 | ### Added 10 | - `Versioning.Controller` which sets up support for usage with Phoenix controllers. 11 | - `Versioning.Plug` which sets up a Plug.Conn to support versioning. 12 | - `Versioning.View` which sets up support for usage with Phoenix views. 13 | - `Versioning.update_data/3` added for easier dynamic versioning data updates. 14 | 15 | ### Changed 16 | - Schema reflection on the latest version requires passing the type of the version 17 | requested - either `:string` or `:parsed` 18 | - The latest version on a schema can be modified with the `@latest` attribute. 19 | - `Versioning.ExectionError` - raised when running a versioning through a schema - 20 | was replaced with `VersioningError`. 21 | - The tuple that represents a version within a schema now includes the string version. 22 | 23 | ## [0.1.1] - 2018-11-11 24 | 25 | ### Added 26 | - `Versioning.Changelog` which documents a changelog for a `Versioning.Schema`. 27 | - `Versioning.Changelog.Formatter` behaviour which allows a bare changelog map to be formatted in custom ways. 28 | - `Versioning.Changelog.Markdown` formatter that turns a changelog into a markdown string. 29 | - A module adhering to the `Versioning.Change` behaviour can now add the `@desc` attribute which the changelog will use. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Nicholas Sweeting 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Versioning 2 | 3 | [![Build Status](https://travis-ci.org/nsweeting/versioning.svg?branch=master)](https://travis-ci.org/nsweeting/versioning) 4 | [![Versioning Version](https://img.shields.io/hexpm/v/versioning.svg)](https://hex.pm/packages/versioning) 5 | 6 | Versioning provides a way for API's to remain backward compatible without the headache. 7 | 8 | This is done through use of a "versioning schema" that translates data through a series 9 | of steps from its current version to the target version. This technique is well 10 | described in the article [APIs as infrastructure: future-proofing Stripe with versioning](https://stripe.com/blog/api-versioning). 11 | 12 | The basic rule is each API version in the schema must only ever concern itself with 13 | creating a set of change modules associated with the version below/above it. This 14 | contract ensures that we can continue to translate data to legacy versions without 15 | enormous effort. 16 | 17 | ## Installation 18 | 19 | The package can be installed by adding `versioning` to your list of dependencies in `mix.exs`: 20 | 21 | ```elixir 22 | def deps do 23 | [ 24 | {:versioning, "~> 0.4"} 25 | ] 26 | end 27 | ``` 28 | 29 | ## Documentation 30 | 31 | See [HexDocs](https://hexdocs.pm/versioning) for additional documentation. 32 | 33 | ## Example 34 | 35 | For a more in-depth example, please check out the [Getting Started](https://hexdocs.pm/versioning/getting-started.html) page. 36 | 37 | We build a schema to describe updates to our API through versions, types, and changes. 38 | 39 | ```elixir 40 | defmodule MyAPI.Versioning do 41 | use Versioning.Schema 42 | 43 | version("1.2.0", do: []) 44 | 45 | version "1.1.0" do 46 | type "Post" do 47 | change(MyAPI.Changes.PostStatusChange) 48 | end 49 | end 50 | 51 | version("1.0.0", do: []) 52 | end 53 | ``` 54 | 55 | We build a change module to perform data modifications. 56 | 57 | ```elixir 58 | defmodule MyAPI.Changes.PostStatusChange do 59 | use Versioning.Change 60 | 61 | @desc """ 62 | The boolean field "active" was removed in favour of the enum "status". 63 | """ 64 | 65 | def down(versioning, _opts) do 66 | case Versioning.pop_data(versioning, "status") do 67 | {"active", versioning} -> Versioning.put_data(versioning, "active", true) 68 | {_, versioning} -> Versioning.put_data(versioning, "active", false) 69 | end 70 | end 71 | 72 | def up(versioning, _opts) do 73 | case Versioning.pop_data(versioning, "active") do 74 | {true, versioning} -> Versioning.put_data(versioning, "status", "active") 75 | {false, versioning} -> Versioning.put_data(versioning, "status", "hidden") 76 | {_, versioning} -> versioning 77 | end 78 | end 79 | end 80 | ``` 81 | 82 | We create versionings to run against our schema. Here, we want to change our 83 | post data from version 1.2.0 to 1.0.0. We can then run our versioning against 84 | the schema, which will return a modified versioning with our change modules run. 85 | 86 | ```elixir 87 | versioning = Versioning.new(%Post{}, "1.2.0", "1.0.0") 88 | MyAPI.Versioning.run(versioning) 89 | ``` 90 | 91 | ## Phoenix Usage 92 | 93 | Versioning provides extensive support for usage with Phoenix. Checkout the [Phoenix Usage](https://hexdocs.pm/versioning/phoenix-usage.html) page for more details. 94 | -------------------------------------------------------------------------------- /guides/Getting Started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | Lets say we have a `Post` struct that contains the boolean field `:active`. As time goes by, we recognize that there may be more kinds of statuses that our `Post`'s may have. 4 | 5 | To keep up with the times, we replace our `:active` field with the enum field `:status`. 6 | One of the values could be `"active"` - among many others. 7 | 8 | We arent ready to release this feature into the the wild yet though. So, while we change the internal structure of our `Post` data, we must ensure we dont break the API contract we made with our users. Versioning to the rescue... 9 | 10 | ## Versioning Struct 11 | 12 | A the heart of our versioning is the `Versioning` struct. A `Versioning` struct contains the following fields: 13 | 14 | - `:current` - The current version that our data represents. 15 | - `:target` - The version that we want our data to be changed into. 16 | - `:type` - The type of data we are working with. If we are working with structs, this will typically be the struct name, eg: `Post` 17 | - `:data` - The underlying data that we want to change. For structs, like our `Post`, be aware that we typically have our data as a bare map since it is easier to transform. 18 | - `:changed` - A boolean representing whether a change operation has occured. 19 | - `:assigns` - A map of arbitrary data we can use to store additonal information in. 20 | 21 | Let's see a couple different ways we can create a versioning a `Post`. 22 | 23 | ```elixir 24 | # The type is automatically inferred from the struct module. 25 | Versioning.new(%Post{}, "2.0.0", "1.0.0") 26 | 27 | # We can also explicely set the type. 28 | Versioning.new(%{}, "2.0.0", "1.0.0", Post) 29 | 30 | # We can also build up our versioning using helpers. 31 | |> Versioning.new() 32 | |> Versioning.put_data(post) 33 | |> Versioning.put_type(Post) 34 | |> Versioning.put_current("2.0.0") 35 | |> Versioning.put_target("1.0.0") 36 | ``` 37 | 38 | We now have a versioning of our `Post`. 39 | 40 | ## Versioning Change 41 | 42 | Lets create of first "versioning change". This is a module that adheres to the 43 | `Versioning.Change` behaviour. From it, we must implement the callbacks `up/2` 44 | and `down/2`. 45 | 46 | ```elixir 47 | defmodule MyAPI.Changes.PostStatusChange do 48 | use Versioning.Change 49 | 50 | @desc """ 51 | The boolean field "active" was removed in favour of the enum "status". 52 | """ 53 | 54 | def down(versioning, _opts) do 55 | case Versioning.pop_data(versioning, :status) do 56 | {:active, versioning} -> Versioning.put_data(versioning, :active, true) 57 | {_, versioning} -> Versioning.put_data(versioning, :active, false) 58 | end 59 | end 60 | 61 | def up(versioning, _opts) do 62 | case Versioning.pop_data(versioning, "active") do 63 | {true, versioning} -> Versioning.put_data(versioning, "status", "active") 64 | {false, versioning} -> Versioning.put_data(versioning, "status", "hidden") 65 | {_, versioning} -> versioning 66 | end 67 | end 68 | end 69 | ``` 70 | 71 | Our `down/2` function accepts a versioning, removes the new `:status` value, and 72 | translates it to the old `:active` requirements - returning a modified versioning. 73 | 74 | Our `up/2` function accepts a versioning, removes the old `"active"` value, and 75 | translates it to our new `"status"` requirements - returning a modified versioning. 76 | 77 | We can also use the `@desc` module attribute to attach a description of the change. 78 | This will be used when generating a changelog. 79 | 80 | ## Versioning Schema 81 | 82 | With our first change module in place, its time to tie it all together with our 83 | "versioning schema". The schema provides a DSL to describe and route our versioning. 84 | 85 | ```elixir 86 | defmodule MyAPI.Versioning do 87 | use Versioning.Schema 88 | 89 | version("2.0.0", do: []) 90 | 91 | version "1.1.0" do 92 | type "Post" do 93 | change(MyAPI.Changes.PostStatusChange) 94 | end 95 | end 96 | 97 | version("1.0.0", do: []) 98 | end 99 | ``` 100 | 101 | The schema above shows we currently support 3 versions. Our top version `"2.0.0"` 102 | represents the current version. `"1.1.0"` is where our new article change is held. 103 | The schema DSL describes a flow, whereby the "top" version represents the most recent, 104 | and each subsequent version is one step older. 105 | 106 | ## Running our Versioning 107 | 108 | With our versioning in place, we can now translate our `Post` struct to the requirements 109 | of our users "pinned" API version. 110 | 111 | ```elixir 112 | #For the sake of example, lets say the user is pinned at the older "1.0.0" version. 113 | version = get_api_version(user) 114 | post = get_post(id) 115 | versioning = Versioning.new(post, "2.0.0", version) 116 | 117 | MyAPI.Versioning.run(versioning) 118 | #Versioning 119 | ``` 120 | 121 | Calling `run/1` on our schema with a versioning struct will execute our schema 122 | downwards/upwards depending on the order of our current and target versions. It 123 | will "walk" through each version, running any changes held within it that match 124 | the `:type` on our versioning struct. A schema is typically run "downwards" when 125 | converting local data to external data. A schema is typically run "updwards" when 126 | converting external data to local data. 127 | 128 | Once a matching version is found, it will run the changes within, but will stop 129 | execution afterwards. 130 | 131 | We can then access the underlying data through our `versioning.data`. 132 | 133 | ## Versioning Changelog 134 | 135 | A changelog of our schema can also be generated. This changelog represents a list 136 | of maps in the format (shortend for brevity): 137 | 138 | ```elixir 139 | [ 140 | %{ 141 | version: "2.0.0", 142 | changes: [] 143 | }, 144 | %{ 145 | version: "1.1.0", 146 | changes: [ 147 | %{ 148 | type: Post, 149 | descriptions: [ 150 | "The boolean field `:active` was removed in favour of the enum `:status`." 151 | ] 152 | } 153 | ] 154 | }, 155 | %{ 156 | version: "1.0.0", 157 | changes: [] 158 | } 159 | ] 160 | ``` 161 | 162 | You can access the changelog while providing options such as a formatter. 163 | Included with `Versioning` is a basic markdown formatter. 164 | 165 | ```elixir 166 | Versioning.Changelog.build(MyAPI.Versioning, formatter: Versioning.Changelog.Markdown) 167 | ``` 168 | -------------------------------------------------------------------------------- /guides/Phoenix Usage.md: -------------------------------------------------------------------------------- 1 | # Phoenix Usage 2 | 3 | Versioning provides extensive support for its usage with `Phoenix`. The best way 4 | to get started is by adding some helper functions to your controllers and views. 5 | 6 | defmodule YourAppWeb do 7 | # ... 8 | 9 | def controller do 10 | quote do 11 | use Phoenix.Controller, namespace: MyAppWeb 12 | 13 | # ... 14 | 15 | import Versioning.Controller 16 | 17 | # ... 18 | end 19 | end 20 | 21 | def view do 22 | quote do 23 | use Phoenix.View, root: "lib/your_app_web/templates", namespace: "web" 24 | 25 | # ... 26 | 27 | import Versioning.View 28 | 29 | # ... 30 | end 31 | end 32 | end 33 | 34 | Followed by adding a plug to your endpoint. Typically, you'll want this plug to 35 | be added after you have access to the "current user". 36 | 37 | defmodule YourAppWeb.Endpoint do 38 | use Phoenix.Endpoint, otp_app: :your_app 39 | 40 | # plug ... 41 | 42 | plug Versioning.Plug, schema: YourAppWeb.Versioning 43 | plug YourAppWeb.Router 44 | end 45 | 46 | Beyond the above, you'll of course need to setup your versioning schema and changes. 47 | 48 | But with the above, you should be good to go! Please consult the documentation 49 | available at `Versioning.Controller` for help on how to version params. And please 50 | see the documentation available at `Versioning.View` for help on how to version 51 | responses. 52 | -------------------------------------------------------------------------------- /lib/versioning.ex: -------------------------------------------------------------------------------- 1 | defmodule Versioning do 2 | @moduledoc """ 3 | Versionings allow data to be changed to different versions of itself. 4 | 5 | A the heart of our versioning is the `Versioning` struct. A `Versioning` struct 6 | contains the following fields: 7 | 8 | - `:current` - The current version that our data represents. 9 | - `:target` - The version that we want our data to be changed into. 10 | - `:type` - The type of data we are working with. If we are working with structs, 11 | this will typically be the struct name in string format, eg: `"Post"` 12 | - `:data` - The underlying data that we want to change. For structs, like our 13 | `Post`, be aware that we typically have our data as a bare map since it 14 | is easier to transform. 15 | - `:changes` - A list of change modules that have been applied against the versioning. 16 | The first change module would be the most recent module run. 17 | - `:changed` - A boolean representing if change modules have been applied. 18 | - `:assigns` - A map of arbitrary data we can use to store additonal information in. 19 | 20 | ## Example 21 | 22 | Versioning.new(%Post{}, "2.0.0", "1.0.0") 23 | 24 | With the above, we have created a versioning of a `Post` struct. This represents 25 | us wanting to transform our post from a version "2.0.0" to an older "1.0.0" 26 | version. 27 | 28 | ## Schemas 29 | 30 | The versioning struct is used in combination with a `Versioning.Schema`, which 31 | allows us to map out the changes that should occur through versions. Please see 32 | the `Versioning.Schema` documentation for more details. 33 | """ 34 | 35 | @derive {Inspect, only: [:type, :current, :target, :data, :changed]} 36 | defstruct [ 37 | :current, 38 | :target, 39 | :parsed_current, 40 | :parsed_target, 41 | :type, 42 | :schema, 43 | data: %{}, 44 | assigns: %{}, 45 | changed: false, 46 | changes: [] 47 | ] 48 | 49 | @type version :: binary() | nil 50 | @type type :: binary() | nil 51 | @type data :: %{optional(binary()) => any()} 52 | @type assigns :: %{optional(atom()) => any()} 53 | @type t :: %__MODULE__{ 54 | current: version(), 55 | target: version(), 56 | type: type(), 57 | data: map(), 58 | schema: Versioning.Schema.t(), 59 | assigns: assigns(), 60 | changed: boolean(), 61 | changes: [Versioning.Change.t()] 62 | } 63 | 64 | @doc """ 65 | Creates a new versioning using the data provided. 66 | 67 | If a struct is the data, and no type is provided, the struct module is set as 68 | the versioning `:type` (as described in `put_type/2`), and the struct is turned 69 | into a string-key map that is used for the `:data`. 70 | 71 | ## Examples 72 | 73 | # These are equivalent 74 | Versioning.new(%{"foo" => "bar"}, "2.0.0", "1.0.0", SomeData) 75 | Versioning.new(%{foo: "bar"}, "2.0.0", "1.0.0", "SomeData") 76 | Versioning.new(%SomeData{foo: "bar"}, "2.0.0", "1.0.0") 77 | 78 | """ 79 | @spec new(map(), version(), version(), type()) :: Verisoning.t() 80 | def new(data \\ %{}, current \\ nil, target \\ nil, type \\ nil) 81 | 82 | def new(%{__struct__: struct_type} = data, current, target, type) do 83 | data = Map.from_struct(data) 84 | new(data, current, target, type || struct_type) 85 | end 86 | 87 | def new(data, current, target, type) when is_map(data) do 88 | %Versioning{} 89 | |> put_data(data) 90 | |> put_current(current) 91 | |> put_target(target) 92 | |> put_type(type) 93 | end 94 | 95 | @doc """ 96 | Puts the current version that the data represents. 97 | 98 | The version should be represented somewhere within your `Versioning.Schema`. 99 | This will become the "starting" point from which change modules will be run. 100 | 101 | ## Examples 102 | 103 | Versioning.put_current(versioning, "0.1.0") 104 | 105 | """ 106 | @spec put_current(Versioning.t(), version()) :: Versioning.t() 107 | def put_current(%Versioning{} = versioning, current) do 108 | %{versioning | current: current} 109 | end 110 | 111 | @doc """ 112 | Puts the target version that the data will be transformed to. 113 | 114 | The version should be represented somewhere within your `Versioning.Schema`. 115 | Once the change modules in the target version are run, no more changes will 116 | be made. 117 | 118 | ## Examples 119 | 120 | Versioning.put_target(versioning, "0.1.0") 121 | 122 | """ 123 | @spec put_target(Versioning.t(), version()) :: Versioning.t() 124 | def put_target(%Versioning{} = versioning, target) do 125 | %{versioning | target: target} 126 | end 127 | 128 | @doc """ 129 | Puts the type of the versioning data. 130 | 131 | Typically, if working with data that is associated with a struct, this will 132 | be the struct trailing module name in binary format. For example, 133 | `MyApp.Foo` will be represented as `"Foo"`. 134 | 135 | When running a versioning through a schema, only the changes that match the 136 | type set on the versioning will be run. 137 | 138 | ## Examples 139 | 140 | # These are equivalent 141 | Versioning.put_type(versioning, "Post") 142 | Versioning.put_type(versioning, MyApp.Post) 143 | 144 | """ 145 | @spec put_type(Versioning.t(), type() | atom()) :: Versioning.t() 146 | def put_type(%Versioning{} = versioning, nil) do 147 | %{versioning | type: nil} 148 | end 149 | 150 | def put_type(%Versioning{} = versioning, type) when is_atom(type) do 151 | type = 152 | type 153 | |> to_string() 154 | |> String.split(".") 155 | |> List.last() 156 | 157 | put_type(versioning, type) 158 | end 159 | 160 | def put_type(%Versioning{} = versioning, type) when is_binary(type) do 161 | %{versioning | type: type} 162 | end 163 | 164 | @doc """ 165 | Assigns a value to a key in the versioning. 166 | 167 | The “assigns” storage is meant to be used to store values in the versioning so 168 | that change modules in your schema can access them. The assigns storage is a map. 169 | 170 | ## Examples 171 | 172 | iex> versioning.assigns[:hello] 173 | nil 174 | iex> versioning = Versioning.assign(versioning, :hello, :world) 175 | iex> versioning.assigns[:hello] 176 | :world 177 | 178 | """ 179 | @spec assign(Versioning.t(), atom(), any()) :: Versioning.t() 180 | def assign(%Versioning{assigns: assigns} = versioning, key, value) do 181 | %{versioning | assigns: Map.put(assigns, key, value)} 182 | end 183 | 184 | @doc """ 185 | Returns and removes the value associated with `key` within the `data` of `versioning`. 186 | 187 | If `key` is present in `data` with value `value`, `{value, new_versioning}` is 188 | returned where `new_versioning` is the result of removing `key` from `data`. If `key` 189 | is not present in `data`, `{default, new_versioning}` is returned. 190 | 191 | ## Examples 192 | 193 | iex> Versioning.pop_data(versioning, "foo") 194 | {"bar", versioning} 195 | iex> Versioning.pop_data(versioning, "foo") 196 | {nil, versioning} 197 | 198 | """ 199 | @spec pop_data(Versioning.t(), any()) :: {any(), Versioning.t()} 200 | def pop_data(%Versioning{data: data} = versioning, key, default \\ nil) do 201 | {result, data} = Map.pop(data, key, default) 202 | {result, %{versioning | data: data}} 203 | end 204 | 205 | @doc """ 206 | Gets the value for a specific `key` in the `data` of `versioning`. 207 | 208 | If `key` is present in `data` with value `value`, then `value` is returned. 209 | Otherwise, `default` is returned (which is `nil` unless specified otherwise). 210 | 211 | ## Examples 212 | 213 | iex> Versioning.get_data(versioning, "foo") 214 | "bar" 215 | iex> Versioning.get_data(versioning, "bar") 216 | nil 217 | iex> Versioning.get_data(versioning, "bar", "baz") 218 | "baz" 219 | 220 | """ 221 | @spec get_data(Versioning.t(), binary(), term()) :: any() 222 | def get_data(%Versioning{data: data}, key, default \\ nil) do 223 | Map.get(data, key, default) 224 | end 225 | 226 | @doc """ 227 | Fetches the value for a specific `key` in the `data` of `versioning`. 228 | 229 | If `data` contains the given `key` with value `value`, then `{:ok, value}` is 230 | returned. If `data` doesn't contain `key`, `:error` is returned. 231 | 232 | ## Examples 233 | 234 | iex> Versioning.fetch_data(versioning, "foo") 235 | {:ok, "bar"} 236 | iex> Versioning.fetch_data(versioning, "bar") 237 | :error 238 | 239 | """ 240 | @spec fetch_data(Versioning.t(), binary()) :: {:ok, any()} | :error 241 | def fetch_data(%Versioning{data: data}, key) do 242 | Map.fetch(data, key) 243 | end 244 | 245 | @doc """ 246 | Puts the full data in the versioning. 247 | 248 | The data represents the base of what will be modified when a versioning is 249 | run through a schema. 250 | 251 | Data must be a map. If a struct is provided, the struct will be turned into 252 | a basic map - though its type information will not be inferred. 253 | 254 | The keys of data will always be strings. If passed an 255 | 256 | ## Examples 257 | 258 | iex> versioning = Versioning.put_data(versioning, %{"foo" => "bar"}) 259 | iex> versioning.data 260 | %{"foo" => "bar"} 261 | 262 | """ 263 | @spec put_data(Versioning.t(), map()) :: Versioning.t() 264 | def put_data(%Versioning{} = versioning, data) when is_map(data) do 265 | data = deep_stringify(data) 266 | %{versioning | data: data} 267 | end 268 | 269 | @doc """ 270 | Puts the given `value` under `key` within the `data` of `versioning`. 271 | 272 | ## Examples 273 | iex> Versioning.put_data(versioning, "foo", "bar") 274 | iex> versioning.data["foo"] 275 | "bar" 276 | 277 | """ 278 | @spec put_data(Versioning.t(), binary(), any()) :: Versioning.t() 279 | def put_data(%Versioning{data: data} = versioning, key, value) 280 | when is_map(data) and is_binary(key) do 281 | value = if is_map(value), do: deep_stringify(value), else: value 282 | %{versioning | data: Map.put(data, key, value)} 283 | end 284 | 285 | @doc """ 286 | Updates the `key` within the `data` of `versioning` using the given function. 287 | 288 | If the `data` does not contain `key` - nothing occurs. If it does, the `fun` 289 | is invoked with argument `value` and its result is used as the new value of 290 | `key`. 291 | 292 | ## Examples 293 | iex> Versioning.update_data(versioning, "foo", fn _val -> "bar" end) 294 | iex> versioning.data["foo"] 295 | "bar" 296 | 297 | """ 298 | @spec update_data(Versioning.t(), binary(), (any() -> any())) :: Versioning.t() 299 | def update_data(%Versioning{} = versioning, key, fun) 300 | when is_binary(key) and is_function(fun, 1) do 301 | if Map.has_key?(versioning.data, key) do 302 | val = fun.(versioning.data[key]) 303 | put_data(versioning, key, val) 304 | else 305 | versioning 306 | end 307 | end 308 | 309 | defp deep_stringify(%{__struct__: _} = struct) do 310 | struct |> Map.from_struct() |> deep_stringify() 311 | end 312 | 313 | defp deep_stringify(map) when is_map(map) do 314 | Enum.reduce(map, %{}, fn 315 | {key, val}, acc when is_map(val) -> 316 | val = deep_stringify(val) 317 | Map.put(acc, to_string(key), val) 318 | 319 | {key, val}, acc -> 320 | Map.put(acc, to_string(key), val) 321 | end) 322 | end 323 | end 324 | -------------------------------------------------------------------------------- /lib/versioning/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Versioning.Adapter do 2 | @moduledoc """ 3 | Defines a versioning adapter. 4 | 5 | A versioning adapter is used to parse and compare versions. This allows versions 6 | to be defined in a variety of ways - from semantic, to date based. 7 | 8 | """ 9 | @type t :: module() 10 | 11 | @doc """ 12 | Callback invoked to parse a binary version. 13 | 14 | Returns `{:ok, term}` on success, where `term` will be the adapters representation 15 | of a version. Returns `:error` if the version cannot be parsed. 16 | """ 17 | @callback parse(version :: term()) :: {:ok, term()} | :error 18 | 19 | @doc """ 20 | Callback invoked to compare versions. 21 | 22 | Returns `:gt` if the first verison is greater than the second, and `:lt` for 23 | vice-versa. If the two versions are equal, `:eq` is returned. 24 | """ 25 | @callback compare(version :: term(), version :: term()) :: :gt | :lt | :eq | :error 26 | 27 | @doc false 28 | @spec parse(adapter :: Versioning.Adapter.t(), binary()) :: {:ok, term()} | :error 29 | def parse(adapter, version) do 30 | adapter.parse(version) 31 | end 32 | 33 | @doc false 34 | @spec compare(adapter :: Versioning.Adapter.t(), binary(), binary()) :: :gt | :lt | :eq | :error 35 | def compare(adapter, version1, version2) do 36 | adapter.compare(version1, version2) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/versioning/adapter/date.ex: -------------------------------------------------------------------------------- 1 | defmodule Versioning.Adapter.Date do 2 | @moduledoc """ 3 | A versioning adapter for date-based versions. 4 | 5 | Under the hood, this adapter uses the `Date` module. For details on the rules 6 | that are used for parsing and comparison, please see the `Date` module. 7 | 8 | ## Example 9 | 10 | defmodule MyApp.Versioning do 11 | use Versioning.Schema, adapter: Versioning.Adapter.Date 12 | 13 | version "2019-01-01" do 14 | type "Post" do 15 | change(MyApp.Change) 16 | end 17 | end 18 | end 19 | 20 | """ 21 | 22 | @behaviour Versioning.Adapter 23 | 24 | @doc """ 25 | Parses date based versions using ISO8601 formatting. 26 | 27 | ## Example 28 | 29 | iex> Versioning.Adapters.Date.parse("2019-01-01") 30 | {:ok, ~D[2019-01-01]} 31 | iex> Versioning.Adapters.Date.parse("foo") 32 | :error 33 | 34 | """ 35 | @impl Versioning.Adapter 36 | @spec parse(binary() | Date.t()) :: :error | {:ok, Date.t()} 37 | def parse(version) when is_binary(version) do 38 | case Date.from_iso8601(version) do 39 | {:ok, _} = result -> result 40 | _ -> :error 41 | end 42 | end 43 | 44 | def parse(%Date{} = version) do 45 | {:ok, version} 46 | end 47 | 48 | def parse(_) do 49 | :error 50 | end 51 | 52 | @doc """ 53 | Compares date based versions using ISO8601 formatting. 54 | 55 | Returns `:gt` if the first verison is greater than the second, and `:lt` for 56 | vice-versa. If the two versions are equal, `:eq` is returned. Returns `:error` 57 | if the version cannot be parsed. 58 | 59 | ## Example 60 | 61 | iex> Versioning.Adapters.Date.compare("2019-01-01", "2018-12-31") 62 | :gt 63 | iex> Versioning.Adapters.Date.compare("2018-12-31", "2019-01-01") 64 | :lt 65 | iex> Versioning.Adapters.Date.compare("2019-01-01", "2019-01-01") 66 | :eq 67 | iex> Versioning.Adapters.Date.compare("foo", "bar") 68 | :error 69 | 70 | """ 71 | @impl Versioning.Adapter 72 | @spec compare(binary() | Date.t(), binary() | Date.t()) :: :gt | :lt | :eq | :error 73 | def compare(version1, version2) when is_binary(version1) and is_binary(version2) do 74 | with {:ok, version1} <- parse(version1), 75 | {:ok, version2} <- parse(version2) do 76 | compare(version1, version2) 77 | end 78 | end 79 | 80 | def compare(%Date{} = version1, %Date{} = version2) do 81 | Date.compare(version1, version2) 82 | rescue 83 | _ -> :error 84 | end 85 | 86 | def compare(_version1, _version2) do 87 | :error 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/versioning/adapter/semantic.ex: -------------------------------------------------------------------------------- 1 | defmodule Versioning.Adapter.Semantic do 2 | @moduledoc """ 3 | A versioning adapter for semantic-based versions. 4 | 5 | Under the hood, this adapter uses the `Version` module. For details on the rules 6 | that are used for parsing and comparison, please see the `Version` module. 7 | 8 | ## Example 9 | 10 | defmodule MyApp.Versioning do 11 | use Versioning.Schema, adapter: Versioning.Adapter.Semantic 12 | 13 | version "1.0.0" do 14 | type "Post" do 15 | change(MyApp.Change) 16 | end 17 | end 18 | end 19 | 20 | """ 21 | 22 | @behaviour Versioning.Adapter 23 | 24 | @doc """ 25 | Parses semantic based versions. 26 | 27 | ## Example 28 | 29 | iex> Versioning.Adapter.Semantic.parse("1.0.0") 30 | {:ok, #Version<1.0.0>} 31 | iex> Versioning.Adapter.Semantic.parse("foo") 32 | :error 33 | 34 | """ 35 | @impl Versioning.Adapter 36 | @spec parse(binary() | Version.t()) :: :error | {:ok, Version.t()} 37 | def parse(version) when is_binary(version) do 38 | Version.parse(version) 39 | end 40 | 41 | def parse(%Version{} = version) do 42 | {:ok, version} 43 | end 44 | 45 | def parse(_) do 46 | :error 47 | end 48 | 49 | @doc """ 50 | Compares semantic based versions. 51 | 52 | Returns `:gt` if the first verison is greater than the second, and `:lt` for 53 | vice-versa. If the two versions are equal, `:eq` is returned. Returns `:error` 54 | if the version cannot be parsed. 55 | 56 | ## Example 57 | 58 | iex> Versioning.Adapter.Semantic.compare("1.0.1", "1.0.0") 59 | :gt 60 | iex> Versioning.Adapter.Semantic.compare("1.0.0", "1.0.1) 61 | :lt 62 | iex> Versioning.Adapter.Semantic.compare("1.0.1", "1.0.1") 63 | :eq 64 | iex> Versioning.Adapter.Semantic.compare("foo", "bar") 65 | :error 66 | 67 | """ 68 | @impl Versioning.Adapter 69 | @spec compare(binary() | Version.t(), binary() | Version.t()) :: :eq | :error | :gt | :lt 70 | def compare(version1, version2) when is_binary(version1) and is_binary(version2) do 71 | with {:ok, version1} <- parse(version1), 72 | {:ok, version2} <- parse(version2) do 73 | compare(version1, version2) 74 | end 75 | end 76 | 77 | def compare(%Version{} = version1, %Version{} = version2) do 78 | Version.compare(version1, version2) 79 | rescue 80 | _ -> :error 81 | end 82 | 83 | def compare(_version1, _version2) do 84 | :error 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/versioning/change.ex: -------------------------------------------------------------------------------- 1 | defmodule Versioning.Change do 2 | @moduledoc """ 3 | Defines a versioning change. 4 | 5 | A versioning change is used to make small changes to data of a certain type. 6 | They are used within a `Versioning.Schema`. Changes should attempt to be as 7 | focused as possible to ensure complexity is kept to a minimum. 8 | 9 | ## Example 10 | 11 | defmodule MyApp.Cheanges.PostStatusChange do 12 | use Versioning.Change 13 | 14 | @desc "The 'active' attribute has been changed in favour of the 'status' attribute" 15 | 16 | @impl Versioning.Change 17 | def down(versioning, _opts) do 18 | case Versioning.pop_data(versioning, "status") do 19 | {:active, versioning} -> Versioning.put_data(versioning, "active", true) 20 | {_, versioning} -> Versioning.put_data(versioning, "active", false) 21 | end 22 | end 23 | 24 | @impl Versioning.Change 25 | def up(versioning, _opts) do 26 | case Versioning.pop_data(versioning, "active") do 27 | {true, versioning} -> Versioning.put_data(versioning, "status", "active") 28 | {false, versioning} -> Versioning.put_data(versioning, "status", "hidden") 29 | {_, versioning} -> versioning 30 | end 31 | end 32 | end 33 | 34 | The above change module represents us modifying our `Post` data to support a 35 | new attribute - `status` - which replaces the previous `active` attribute. 36 | 37 | When changing data "down", we must remove the `status` attribte, and replace it 38 | with a value that represents the previous `active` attribute. When changing 39 | data "up", we must remove the `active` attribute and replace it with a value that 40 | represents the new `status` attribute. 41 | 42 | ## Descriptions 43 | 44 | Change modules can optionally include a `@desc` module attribute. This will be 45 | used to describe the changes made in the change module when constructing changelogs. 46 | Please see the `Versioning.Changelog` documentation for more information on changelogs. 47 | """ 48 | 49 | @doc """ 50 | Accepts a `Versioning` struct, and applies changes upward. 51 | 52 | ## Examples 53 | 54 | MyApp.Change.up(versioning) 55 | 56 | """ 57 | @callback up(versioning :: Versioning.t(), opts :: any()) :: Versioning.t() 58 | 59 | @doc """ 60 | Accepts a `Versioning` struct and applies changes downward. 61 | 62 | ## Examples 63 | 64 | MyApp.Change.down(versioning) 65 | 66 | """ 67 | @callback down(versioning :: Versioning.t(), opts :: any()) :: Versioning.t() 68 | 69 | defmacro __using__(_opts) do 70 | quote do 71 | @behaviour Versioning.Change 72 | 73 | @desc "No Description" 74 | 75 | @before_compile Versioning.Change 76 | end 77 | end 78 | 79 | defmacro __before_compile__(_env) do 80 | quote do 81 | def __change__(:desc) do 82 | @desc 83 | end 84 | end 85 | end 86 | 87 | @doc false 88 | @spec up(Versioning.t(), atom(), any()) :: Versioning.t() 89 | def up(versioning, change, opts) do 90 | versioning |> change.up(opts) |> put_change(change) 91 | end 92 | 93 | @doc false 94 | @spec down(Versioning.t(), atom(), any()) :: Versioning.t() 95 | def down(versioning, change, opts) do 96 | versioning |> change.down(opts) |> put_change(change) 97 | end 98 | 99 | defp put_change(versioning, change) do 100 | %{versioning | changed: true, changes: [change | versioning.changes]} 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/versioning/changelog.ex: -------------------------------------------------------------------------------- 1 | defmodule Versioning.Changelog do 2 | @moduledoc """ 3 | Creates changelogs for schemas. 4 | 5 | The changelog is composed of a list of maps that describe the history of 6 | the schema. For example: 7 | 8 | [ 9 | %{ 10 | version: "1.1.0", 11 | changes: [ 12 | %{type: Foo, descriptions: ["Changed this.", "Changed that."]} 13 | ] 14 | }, 15 | %{ 16 | version: "1.0.0", 17 | changes: [ 18 | %{type: Foo, descriptions: ["Changed this.", "Changed that."]} 19 | ] 20 | } 21 | ] 22 | 23 | The descriptions associated with each change can be attributed via the 24 | `@desc` module attribute on a change module. Please see `Versioning.Change` 25 | for more details. 26 | 27 | Formatters can be used to turn the raw changelog into different formats. Please 28 | see the `Versioning.Changelog.Formatter` behaviour for more details. 29 | 30 | The `Versioning.Changelog.Markdown` formatter is included with this package. 31 | """ 32 | 33 | @type change :: %{type: module(), descriptions: [binary()]} 34 | @type version :: %{version: binary(), changes: [change()]} 35 | @type t :: [version()] 36 | 37 | @doc """ 38 | Builds a changelog of the schema. 39 | 40 | ## Options 41 | * `:version` - A specific version within the changelog. 42 | * `:type` - A specific type within the specified version. 43 | * `:formatter` - A module that adheres to the `Versioning.Changelog.Formatter` 44 | behaviour. 45 | 46 | ## Examples 47 | 48 | Versioning.Changelog.build(MySchema, formatter: Versioning.Changelog.Markdown) 49 | 50 | """ 51 | @spec build(Versioning.Schema.t()) :: Versioning.Changelog.t() 52 | def build(schema, opts \\ []) do 53 | version = Keyword.get(opts, :version) 54 | type = Keyword.get(opts, :type) 55 | formatter = Keyword.get(opts, :formatter) 56 | 57 | schema 58 | |> do_build() 59 | |> do_fetch(version, type) 60 | |> do_format(formatter) 61 | end 62 | 63 | defp do_build(schema) do 64 | Enum.reduce(schema.__schema__(:down), [], fn {_version, raw_version, types}, changelog -> 65 | add_version(changelog, raw_version, types) 66 | end) 67 | end 68 | 69 | defp add_version(changelog, raw_version, types) do 70 | changelog ++ [%{version: raw_version, changes: build_changes(types)}] 71 | end 72 | 73 | defp build_changes(types) do 74 | Enum.reduce(types, [], fn {type, changes}, result -> 75 | add_change(result, type, changes) 76 | end) 77 | end 78 | 79 | defp add_change(current, type, changes) do 80 | current ++ [%{type: type, descriptions: build_descriptions(changes)}] 81 | end 82 | 83 | defp build_descriptions(changes) do 84 | Enum.reduce(changes, [], fn {change, _init}, descriptions -> 85 | descriptions ++ [change.__change__(:desc)] 86 | end) 87 | end 88 | 89 | defp do_fetch(changelog, nil, nil) do 90 | changelog 91 | end 92 | 93 | defp do_fetch(_changelog, nil, type) when is_atom(type) do 94 | raise Versioning.ChangelogError, """ 95 | cannot fetch a changelog type without a version. 96 | 97 | type: #{inspect(type)} 98 | """ 99 | end 100 | 101 | defp do_fetch(changelog, version, nil) do 102 | do_get_version(changelog, version) 103 | end 104 | 105 | defp do_fetch(changelog, version, type) do 106 | changelog 107 | |> do_get_version(version) 108 | |> do_get_version_type(type) 109 | end 110 | 111 | defp do_get_version(changelog, version) do 112 | Enum.find(changelog, &(Map.get(&1, :version) == to_string(version))) || 113 | invalid_version!(version) 114 | end 115 | 116 | defp do_get_version_type(version, type) do 117 | version 118 | |> Map.get(:changes) 119 | |> Enum.find(&(Map.get(&1, :type) == type)) 120 | end 121 | 122 | defp do_format(changelog, nil) do 123 | changelog 124 | end 125 | 126 | defp do_format(changelog, formatter) do 127 | formatter.format(changelog) 128 | end 129 | 130 | defp invalid_version!(version) do 131 | raise Versioning.ChangelogError, """ 132 | version cannot be found in schema. 133 | 134 | version: #{inspect(version)} 135 | """ 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/versioning/changelog/formatter.ex: -------------------------------------------------------------------------------- 1 | defmodule Versioning.Changelog.Formatter do 2 | @moduledoc """ 3 | Defines a versioning changelog formatter. 4 | 5 | A changelog formatter is used to create custom outputs from raw changelog data. 6 | Included with this package is the `Versioning.Changelog.Markdown` ormatter. 7 | This accepts the standard changelog data structure, and converts it to a simple 8 | markdown format. 9 | 10 | ## Example 11 | 12 | defmodule MyApp.SomeFormatter do 13 | use Versioning.Changelog.Formatter 14 | 15 | @impl Versioning.Changelog.Formatter 16 | def format(changelog) do 17 | # Do custom formatting 18 | end 19 | end 20 | 21 | Please see the `Versioning.Changelog.Markdown` for an example of its use. 22 | """ 23 | 24 | @doc """ 25 | Formats a changelog. 26 | 27 | Accepts a list of changelog versions, a single version, or a single change, 28 | and returns a custom formatted version. 29 | """ 30 | @callback format( 31 | Versioning.Changelog.t() 32 | | Versioning.Changelog.version() 33 | | Versioning.Changelog.change() 34 | ) :: any() 35 | 36 | defmacro __using__(_opts) do 37 | quote do 38 | @behaviour Versioning.Changelog.Formatter 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/versioning/changelog/markdown.ex: -------------------------------------------------------------------------------- 1 | defmodule Versioning.Changelog.Markdown do 2 | @moduledoc """ 3 | Formats a changelog into a markdown version. 4 | """ 5 | 6 | use Versioning.Changelog.Formatter 7 | 8 | @impl Versioning.Changelog.Formatter 9 | @spec format(binary() | maybe_improper_list() | map()) :: binary() 10 | def format(list) when is_list(list) do 11 | Enum.reduce(list, "", fn item, result -> 12 | result <> format(item) 13 | end) 14 | end 15 | 16 | def format(%{version: version, changes: changes}) do 17 | """ 18 | 19 | ### Version: #{version} 20 | 21 | #{format(changes)}--- 22 | """ 23 | end 24 | 25 | def format(%{type: type, descriptions: descriptions}) do 26 | """ 27 | ##### Resource: #{type} 28 | #{format(descriptions)} 29 | """ 30 | end 31 | 32 | def format(description) when is_binary(description) do 33 | "- #{description}\n" 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/versioning/controller.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Plug) do 2 | defmodule Versioning.Controller do 3 | @moduledoc """ 4 | A set of functions used with `Phoenix` controllers. 5 | 6 | Typically, this module should be imported into your controller modules. In a normal 7 | phoenix application, this can usually be done with the following: 8 | 9 | defmodule YourAppWeb do 10 | # ... 11 | 12 | def controller do 13 | quote do 14 | use Phoenix.Controller, namespace: MyAppWeb 15 | 16 | # ... 17 | 18 | import Versioning.Controller 19 | 20 | # ... 21 | end 22 | end 23 | end 24 | 25 | Please see the documentation at `Phoenix.Controller` for details on how to 26 | set up a typical controller. 27 | 28 | This module is mainly used to convert raw params from the version that the 29 | request data represents, to the latest version that your "context" application 30 | expects. 31 | 32 | ## Example 33 | 34 | Below is an example of how to use versioning in a typical controller: 35 | 36 | defmodule MyController do 37 | use MyAppWeb, :controller 38 | 39 | plug Versioning.Plug, schema: MyVersioningSchema 40 | 41 | def update(conn, %{"id" => id, "user" => params}) do 42 | with {:ok, params} <- params_version(conn, params, "User"), 43 | {:ok, user} <- Blog.fetch_user(id), 44 | {:ok, user} <- Blog.update_user(user) do 45 | render(conn, "show.json", user: user) 46 | end 47 | end 48 | end 49 | 50 | The `params_version/3` function accepts a conn, a set of params representing 51 | whatever version of the data the user uses, as well as the type of the data. 52 | It will run the params through your schema, returning the results. 53 | 54 | """ 55 | 56 | @doc """ 57 | Stores the schema for versioning. 58 | 59 | ## Examples 60 | 61 | Versioning.Controller.put_schema(conn, MySchema) 62 | 63 | """ 64 | @spec put_schema(Plug.Conn.t(), Versioning.Schema.t()) :: Plug.Conn.t() 65 | def put_schema(conn, schema) when is_atom(schema) do 66 | Plug.Conn.put_private(conn, :versioning_schema, schema) 67 | end 68 | 69 | @doc """ 70 | Fetches the current schema. 71 | 72 | Returns `{:ok, schema}` on success, or `:error` if no schema exists. 73 | 74 | ## Examples 75 | 76 | iex> conn = Versioning.Controller.put_schema(conn, MySchema) 77 | iex> Versioning.Controller.fetch_schema(conn) 78 | {:ok, MySchema} 79 | 80 | """ 81 | @spec fetch_schema(Plug.Conn.t()) :: {:ok, Versioning.Schema.t()} | :error 82 | def fetch_schema(conn) do 83 | Map.fetch(conn.private, :versioning_schema) 84 | end 85 | 86 | @doc """ 87 | Fetches the current schema or errors if empty. 88 | 89 | Returns `schema` or raises a `Versioning.MissingSchemaError`. 90 | 91 | ## Examples 92 | 93 | iex> conn = Versioning.Controller.put_schema(conn, MySchema) 94 | iex> Versioning.Controller.fetch_schema!(conn) 95 | MySchema 96 | 97 | """ 98 | @spec fetch_schema!(Plug.Conn.t()) :: Versioning.Schema.t() 99 | def fetch_schema!(conn) do 100 | Map.get(conn.private, :versioning_schema) || raise Versioning.MissingSchemaError 101 | end 102 | 103 | @doc """ 104 | Stores the request version. 105 | 106 | ## Examples 107 | 108 | Versioning.Controller.put_version(conn, "1.0.0") 109 | 110 | """ 111 | @spec put_version(Plug.Conn.t(), binary()) :: Plug.Conn.t() 112 | def put_version(conn, version) do 113 | Plug.Conn.put_private(conn, :versioning_version, version) 114 | end 115 | 116 | @doc """ 117 | Fetches the current request version. 118 | 119 | Returns `{:ok, version}` on success, or `:error` if no version exists. 120 | 121 | ## Examples 122 | 123 | iex> conn = Versioning.Controller.put_version(conn, "1.0.0") 124 | iex> Versioning.Controller.fetch_version(conn) 125 | {:ok, "1.0.0"} 126 | 127 | """ 128 | @spec fetch_version(Plug.Conn.t()) :: {:ok, binary()} | :error 129 | def fetch_version(conn) do 130 | Map.fetch(conn.private, :versioning_version) 131 | end 132 | 133 | @doc """ 134 | Fetches the current request version or errors if empty. 135 | 136 | Returns `version` or raises a `Versioning.MissingVersionError`. 137 | 138 | ## Examples 139 | 140 | iex> conn = Versioning.Controller.put_schema(conn, "1.0.0") 141 | iex> Versioning.Controller.fetch_version!(conn) 142 | "1.0.0" 143 | 144 | """ 145 | @spec fetch_version!(Plug.Conn.t()) :: Versioning.Schema.t() 146 | def fetch_version!(conn) do 147 | Map.get(conn.private, :versioning_version) || raise Versioning.MissingVersionError 148 | end 149 | 150 | @doc """ 151 | Applies a version using the header or fallback. 152 | 153 | The schema must already be stored on the conn to use this function. 154 | 155 | The fallback is used if the header is not present. Its value can be `:latest`, 156 | representing the latest version on the schema, or a `{module, function}`. 157 | This module and function will be called with the conn. 158 | 159 | """ 160 | @spec apply_version(Plug.Conn.t(), binary(), :latest | {module(), atom()}) :: Plug.Conn.t() 161 | def apply_version(conn, header \\ "x-api-version", fallback \\ :latest) do 162 | version = get_version(conn, header, fallback) 163 | put_version(conn, version) 164 | end 165 | 166 | defp get_version(conn, header, fallback) do 167 | case Plug.Conn.get_req_header(conn, header) do 168 | [version] -> 169 | version 170 | 171 | _ -> 172 | case fallback do 173 | :latest -> 174 | schema = fetch_schema!(conn) 175 | schema.__schema__(:latest, :string) 176 | 177 | {mod, fun} -> 178 | apply(mod, fun, [conn]) 179 | end 180 | end 181 | end 182 | 183 | @doc """ 184 | Performs versioning on the `params` using the given `type`. 185 | 186 | The schema and request version must already be stored on the conn to use 187 | this function. 188 | 189 | Returns `{:ok, params}` with the new versioned params, or `{:error, :bad_version}` 190 | if the schema does not contain the version requested. 191 | 192 | """ 193 | @spec params_version(Plug.Conn.t(), map(), binary()) :: {:ok, map()} | {:error, :bad_version} 194 | def params_version(conn, params, type) do 195 | schema = fetch_schema!(conn) 196 | current = fetch_version!(conn) 197 | target = schema.__schema__(:latest, :string) 198 | versioning = Versioning.new(params, current, target, type) 199 | 200 | case schema.run(versioning) do 201 | {:ok, versioning} -> {:ok, versioning.data} 202 | {:error, _error} -> {:error, :bad_version} 203 | end 204 | end 205 | end 206 | end 207 | -------------------------------------------------------------------------------- /lib/versioning/exceptions.ex: -------------------------------------------------------------------------------- 1 | defmodule VersioningError do 2 | defexception [:message] 3 | end 4 | 5 | defmodule Versioning.MissingSchemaError do 6 | defexception message: """ 7 | no schema has been applied to the conn. 8 | 9 | please use Versioning.Controller.put_schema/2 or Versioning.Plug to apply a schema. 10 | """ 11 | end 12 | 13 | defmodule Versioning.MissingVersionError do 14 | defexception message: """ 15 | no version has been applied to the conn. 16 | 17 | please use Versioning.Controller.put_version/2 or Versioning.Plug to apply a version. 18 | """ 19 | end 20 | 21 | defmodule Versioning.CompileError do 22 | defexception [:message] 23 | end 24 | 25 | defmodule Versioning.ChangelogError do 26 | defexception [:message] 27 | end 28 | -------------------------------------------------------------------------------- /lib/versioning/plug.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Plug) do 2 | defmodule Versioning.Plug do 3 | @moduledoc """ 4 | A Plug to assist with versioning an API. 5 | 6 | It requires one option: 7 | 8 | * `:schema`- the versioning schema to use when changing data. 9 | 10 | We can easily add our plug to a pipeline with the following: 11 | 12 | plug Versioning.Plug, schema: MySchema 13 | 14 | The above will store the schema within the conn struct, as well as attempt 15 | to apply a version to the request. This information can then be used when changing 16 | data to and from the latest version. 17 | 18 | ## Options 19 | 20 | * `:header` - the header to read when getting the version requested. This 21 | defaults to `"x-api-version"`. 22 | * `:fallback` - the fallback to occur if no version can be found in the header. 23 | This defaults to `:latest` - which is the latest version in our schema. 24 | 25 | Alternatively, you can specify a `{module, function}`. This enables more 26 | dynamic behaviour - such as fetching a version from a current user or token. 27 | The module and function must have an arity of 1 - the conn struct will be 28 | passed to it. 29 | 30 | ## Examples 31 | This plug can be mounted in a `Plug.Builder` pipeline as follows: 32 | 33 | defmodule MyPlug do 34 | use Plug.Builder 35 | 36 | plug Versioning.Plug, schema: MySchema, header: "myapi-version", fallback: {MyFallback, :call} 37 | plug :not_found 38 | 39 | def not_found(conn, _) do 40 | send_resp(conn, 404, "not found") 41 | end 42 | end 43 | 44 | """ 45 | 46 | @behaviour Plug 47 | 48 | @impl Plug 49 | def init(opts \\ []) do 50 | %{ 51 | schema: Keyword.fetch!(opts, :schema), 52 | header: Keyword.get(opts, :header, "x-api-version"), 53 | fallback: Keyword.get(opts, :fallback, :latest) 54 | } 55 | end 56 | 57 | @impl Plug 58 | def call(conn, %{schema: schema, header: header, fallback: fallback}) do 59 | conn 60 | |> Versioning.Controller.put_schema(schema) 61 | |> Versioning.Controller.apply_version(header, fallback) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/versioning/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Versioning.Schema do 2 | @moduledoc """ 3 | Defines a versioning schema. 4 | 5 | A versioning schema is used to change data through a series of steps from a 6 | "current" version to a "target" version. This is useful in maintaining backwards 7 | compatability with older versions of API's without enormous complication. 8 | 9 | ## Example 10 | 11 | defmodule MyApp.Versioning do 12 | use Versioning.Schema, adapter: Versioning.Adapter.Semantic 13 | 14 | version("2.0.0", do: []) 15 | 16 | version "1.1.0" do 17 | type "User" do 18 | change(MyApp.Changes.SomeUserChange) 19 | end 20 | end 21 | 22 | version "1.0.1" do 23 | type "Post" do 24 | change(MyApp.Changes.SomePostChange)) 25 | end 26 | 27 | type "All!" do 28 | change(MyApp.Changes.SomeAllChange) 29 | end 30 | end 31 | 32 | version("1.0.0", do: []) 33 | end 34 | 35 | When creating a schema, an adapter must be specified. The adapter determines how 36 | versions are parsed and compared. For more information on adapters, please see 37 | `Versioning.Adapter`. 38 | 39 | In the example above, we have 4 versions. Our current version is represented by 40 | the top version - `"2.0.0"`. Our oldest version is at the bottom - `"1.0.0"`. 41 | 42 | We define a version with the `version/2` macro. Within a version, we specify types 43 | that have been manipulated. We define a type with the `type/2` macro. Within 44 | a type, we specify changes that have occured. We define a change with the `change/2` 45 | macro. 46 | 47 | ## Running Schemas 48 | 49 | Lets say we have a `%Post{}` struct that we would like to run through our schema. 50 | 51 | post = %Post{status: :enabled} 52 | Versioning.new(post, "2.0.0", "1.0.0") 53 | 54 | We have created a new versioning of our post struct. The versioning sets the 55 | data, current version, target version, as well as type. We can now run our 56 | versioning through our schema. 57 | 58 | {:ok, versioning} = MyApp.Versioning.run(versioning) 59 | 60 | With the above, our versioning struct will first be run through our MyApp.Changes.SomePostChange 61 | change module as the type matches our versioning type. It will then be run through 62 | our MyApp.Changes.SomeAllChange as it also matches on the `"All!` type (more detail 63 | available at the `change/2` macro). 64 | 65 | With the above, we are transforming our data "down" through our schema. But we 66 | can also transform it "up". 67 | 68 | post = %{"status" => "some_status"} 69 | Versioning.new(post, "1.0.0", "2.0.0", "Post") 70 | 71 | If we were to run our new versioning through the schema, the same change modules 72 | would be run, but in reverse order. 73 | 74 | ## Change Modules 75 | 76 | At the heart of versioning schemas are change modules. You can find more information 77 | about creating change modules at the `Versioning.Change` documentation. 78 | 79 | ## Schema attributes 80 | 81 | Supported attributes for configuring the defined schema. They must be 82 | set after the `use Versioning.Schema` call. 83 | 84 | These attributes are: 85 | 86 | * `@latest` - configures the schema latest version. By default, this will 87 | be the version at the top of your schema. But if you do not wish to have 88 | this behaviour, you can set it here. 89 | 90 | 91 | ## Reflection 92 | 93 | Any schema module will generate the `__schema__` function that can be 94 | used for runtime introspection of the schema: 95 | 96 | * `__schema__(:down)` - Returns the data structure representing a downward versioning. 97 | * `__schema__(:up)` - Returns the data structure representing an upward versioning. 98 | * `__schema__(:adapter)` - Returns the versioning adapter used by the schema. 99 | * `__schema__(:latest, :string)` - Returns the latest version in string format. 100 | * `__schema__(:latest, :parsed)` - Returns the latest version in parsed format. 101 | 102 | """ 103 | 104 | @type t :: module() 105 | 106 | @type direction :: :up | :down 107 | 108 | @type change :: {atom(), list()} 109 | 110 | @type type :: {binary(), [change()]} 111 | 112 | @type version :: {binary(), [type()]} 113 | 114 | @type schema :: [version()] 115 | 116 | @type result :: Versioning.t() | [Versioning.t()] | no_return() 117 | 118 | defmacro __using__(opts) do 119 | adapter = Keyword.get(opts, :adapter) 120 | 121 | unless adapter do 122 | raise ArgumentError, "missing :adapter option on use Versioning.Schema" 123 | end 124 | 125 | quote do 126 | @adapter unquote(adapter) 127 | @latest nil 128 | 129 | def run(versioning_or_versionings) do 130 | Versioning.Schema.Executer.run(__MODULE__, versioning_or_versionings) 131 | end 132 | 133 | import Versioning.Schema, only: [version: 2, type: 2, change: 1, change: 2] 134 | 135 | Module.register_attribute(__MODULE__, :_schema, accumulate: true) 136 | 137 | @before_compile Versioning.Schema 138 | end 139 | end 140 | 141 | @doc """ 142 | Defines a version in the schema. 143 | 144 | A version must be in string format, and must adhere to requirements of the 145 | Elixir `Version` module. This means SemVer 2.0. 146 | 147 | A version can only be represented once within a schema. The most recent version 148 | should be at the top of your schema, and the oldest at the bottom. 149 | 150 | Any issue with the above will raise a `Versioning.CompileError` during schema 151 | compilation. 152 | 153 | ## Example 154 | 155 | version "1.0.1" do 156 | 157 | end 158 | 159 | version("1.0.0", do: []) 160 | """ 161 | defmacro version(version, do: block) do 162 | quote do 163 | @_schema {:version, unquote(version)} 164 | unquote(block) 165 | end 166 | end 167 | 168 | @doc """ 169 | Defines a type within a version. 170 | 171 | A type can only be represented once within a version, and must be a string. Any 172 | issue with this will raise a `Versioning.CompileError` during compilation. 173 | 174 | Typically, it should be represented in `"CamelCase"` format. 175 | 176 | Any changes within a type that matches the type on a `Versioning` struct will 177 | be run. There is also the special case `"All!"` type, which lets you define 178 | changes that will be run against all versionings - regardless of type. 179 | 180 | ## Example 181 | 182 | version "1.0.1" do 183 | type "All!" do 184 | 185 | end 186 | 187 | type "Foo" do 188 | 189 | end 190 | end 191 | """ 192 | defmacro type(object, do: block) do 193 | quote do 194 | @_schema {:type, unquote(object)} 195 | unquote(block) 196 | end 197 | end 198 | 199 | @doc """ 200 | Defines a change within a type. 201 | 202 | A change must be represented by a module that implements the `Versioning.Change` 203 | behaviour. You can also set options that will be passed along to the change module. 204 | 205 | Changes are run in the order they are placed, based on the direction of the 206 | version change. For instance, if a schema was being run "down" for the example below, 207 | MyChangeModule would be run first, followed by MyOtherChangeModule. This would 208 | be reversed if running "up" a schema. 209 | 210 | ## Example 211 | 212 | version "1.0.1" do 213 | type "Foo" do 214 | change(MyChangeModule) 215 | change(MyOtherChangeModule, [foo: :bar]) 216 | end 217 | end 218 | """ 219 | defmacro change(change, init \\ []) do 220 | quote do 221 | @_schema {:change, unquote(change), unquote(init)} 222 | end 223 | end 224 | 225 | defmacro __before_compile__(env) do 226 | {schema_down, schema_up, latest} = Versioning.Schema.Compiler.build(env) 227 | 228 | schema_down = Macro.escape(schema_down) 229 | schema_up = Macro.escape(schema_up) 230 | latest = Macro.escape(latest) 231 | 232 | quote do 233 | def __schema__(:down) do 234 | unquote(schema_down) 235 | end 236 | 237 | def __schema__(:up) do 238 | unquote(schema_up) 239 | end 240 | 241 | def __schema__(:latest, :parsed) do 242 | unquote(latest) 243 | end 244 | 245 | def __schema__(:latest, :string) do 246 | to_string(unquote(latest)) 247 | end 248 | 249 | def __schema__(:adapter) do 250 | @adapter 251 | end 252 | end 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /lib/versioning/schema/compiler.ex: -------------------------------------------------------------------------------- 1 | defmodule Versioning.Schema.Compiler do 2 | @moduledoc false 3 | 4 | @doc false 5 | def build(env) do 6 | schema = Module.get_attribute(env.module, :_schema) 7 | adapter = Module.get_attribute(env.module, :adapter) 8 | latest = Module.get_attribute(env.module, :latest) 9 | 10 | schema_down = do_build(adapter, schema) 11 | schema_up = do_reverse(schema_down) 12 | latest = do_latest(adapter, schema_down, latest) 13 | 14 | {schema_down, schema_up, latest} 15 | end 16 | 17 | defp do_build(adapter, schema) do 18 | schema 19 | |> Enum.reverse() 20 | |> Enum.reduce([], &do_build(adapter, &1, &2)) 21 | |> Enum.reverse() 22 | end 23 | 24 | defp do_build(adapter, {:version, raw_version}, schema) do 25 | case Versioning.Adapter.parse(adapter, raw_version) do 26 | {:ok, parsed_version} -> 27 | validate_version!(schema, adapter, parsed_version) 28 | [{parsed_version, raw_version, []} | schema] 29 | 30 | :error -> 31 | raise Versioning.CompileError, """ 32 | invalid version format for #{inspect(adapter)}. 33 | 34 | version: #{inspect(raw_version)} 35 | """ 36 | end 37 | end 38 | 39 | defp do_build(_adapter, {:type, type}, [{version, raw_version, types} | schema]) do 40 | validate_type!(types, type) 41 | types = types ++ [{type, []}] 42 | [{version, raw_version, types} | schema] 43 | end 44 | 45 | defp do_build(_adapter, {:change, change, init}, [{version, raw_version, objects} | schema]) do 46 | [{object, changes} | objects] = Enum.reverse(objects) 47 | validate_change!(change) 48 | changes = changes ++ [{change, init}] 49 | [{version, raw_version, objects ++ [{object, changes}]} | schema] 50 | end 51 | 52 | defp do_reverse(schema) do 53 | schema 54 | |> Enum.map(fn {version, raw_version, objects} -> 55 | objects = 56 | Enum.map(objects, fn 57 | {object, changes} -> {object, Enum.reverse(changes)} 58 | changes -> changes 59 | end) 60 | 61 | {version, raw_version, Enum.reverse(objects)} 62 | end) 63 | |> Enum.reverse() 64 | end 65 | 66 | defp validate_version!(schema, adapter, version) do 67 | improper_order = 68 | Enum.any?(schema, fn {current_version, _raw_version, _types} -> 69 | case Versioning.Adapter.compare(adapter, version, current_version) do 70 | :lt -> false 71 | _ -> true 72 | end 73 | end) 74 | 75 | if improper_order do 76 | raise Versioning.CompileError, """ 77 | versions are incorrectly ordered. 78 | 79 | version: #{inspect(version)} 80 | """ 81 | end 82 | 83 | :ok 84 | end 85 | 86 | defp validate_type!(types, type) when is_binary(type) do 87 | already_exists = 88 | Enum.any?(types, fn 89 | {current_type, _changes} -> current_type == type 90 | _ -> false 91 | end) 92 | 93 | if already_exists do 94 | raise Versioning.CompileError, """ 95 | cannot have more than one type in a version of a schema. 96 | 97 | type: #{inspect(type)} 98 | """ 99 | end 100 | 101 | :ok 102 | end 103 | 104 | defp validate_type!(_types, type) do 105 | raise Versioning.CompileError, """ 106 | expected type to be a string. 107 | 108 | type: #{inspect(type)} 109 | """ 110 | end 111 | 112 | defp validate_change!(change) when is_atom(change) do 113 | try do 114 | change.module_info() 115 | |> Keyword.get(:attributes, []) 116 | |> Keyword.get(:behaviour, []) 117 | |> Enum.member?(Versioning.Change) 118 | |> case do 119 | true -> :ok 120 | false -> invalid_change_module!(change) 121 | end 122 | rescue 123 | _ -> invalid_change_module!(change) 124 | end 125 | end 126 | 127 | defp validate_change!(change) do 128 | raise Versioning.CompileError, """ 129 | expected change to be an atom. 130 | 131 | change: #{inspect(change)} 132 | """ 133 | end 134 | 135 | defp invalid_change_module!(change) do 136 | raise Versioning.CompileError, """ 137 | expected change to implement the Versioning.Change behaviour. 138 | 139 | change: #{inspect(change)} 140 | """ 141 | end 142 | 143 | defp do_latest(_adapter, [{latest, _, _} | _], nil) do 144 | latest 145 | end 146 | 147 | defp do_latest(adapter, schema, latest) do 148 | validate_latest!(adapter, schema, latest) 149 | {:ok, parsed_latest} = Versioning.Adapter.parse(adapter, latest) 150 | parsed_latest 151 | end 152 | 153 | defp validate_latest!(adapter, schema, latest) do 154 | case Versioning.Adapter.parse(adapter, latest) do 155 | {:ok, parsed_latest} -> 156 | if Enum.find(schema, fn {version, _raw_version, _types} -> parsed_latest == version end) do 157 | :ok 158 | else 159 | raise Versioning.CompileError, """ 160 | invalid @latest schema attribute. version could not be found. 161 | 162 | version: #{inspect(latest)} 163 | """ 164 | end 165 | 166 | :error -> 167 | raise Versioning.CompileError, """ 168 | invalid @latest schema attribute. version could not be parsed. 169 | 170 | version: #{inspect(latest)} 171 | """ 172 | end 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /lib/versioning/schema/executer.ex: -------------------------------------------------------------------------------- 1 | defmodule Versioning.Schema.Executer do 2 | @moduledoc false 3 | 4 | @doc false 5 | def run(schema, versionings) when is_list(versionings) do 6 | Enum.map(versionings, &run(schema, &1)) 7 | end 8 | 9 | def run(schema, versioning) do 10 | adapter = schema.__schema__(:adapter) 11 | 12 | with {:ok, target, current} <- parse_versions(adapter, versioning) do 13 | versioning = %{versioning | schema: schema, parsed_target: target, parsed_current: current} 14 | 15 | case Versioning.Adapter.compare(adapter, target, current) do 16 | :lt -> run(:down, schema, versioning) 17 | :gt -> run(:up, schema, versioning) 18 | :eq -> {:ok, versioning} 19 | end 20 | end 21 | end 22 | 23 | @doc false 24 | def run(direction, schema, %Versioning{} = versioning) do 25 | schema = schema.__schema__(direction) 26 | 27 | with {:ok, schema} <- locate_current(schema, versioning.parsed_current) do 28 | do_run(direction, schema, versioning) 29 | end 30 | end 31 | 32 | defp do_run( 33 | direction, 34 | [{version, _raw_version, types} | _schema], 35 | %Versioning{parsed_target: target} = versioning 36 | ) 37 | when target == version do 38 | {:ok, do_run_types(direction, types, versioning)} 39 | end 40 | 41 | defp do_run(direction, [{_version, _raw_version, types} | schema], versioning) do 42 | versioning = do_run_types(direction, types, versioning) 43 | do_run(direction, schema, versioning) 44 | end 45 | 46 | defp do_run(_direction, [], _versioning) do 47 | {:error, %VersioningError{message: "no matching version found in schema."}} 48 | end 49 | 50 | defp do_run_types(_direction, [], versioning) do 51 | versioning 52 | end 53 | 54 | defp do_run_types(direction, [{"All!", changes} | types], versioning) do 55 | versioning = do_run_changes(direction, changes, versioning) 56 | do_run_types(direction, types, versioning) 57 | end 58 | 59 | defp do_run_types(direction, [{type1, changes} | types], %Versioning{type: type2} = versioning) 60 | when type1 == type2 do 61 | versioning = do_run_changes(direction, changes, versioning) 62 | do_run_types(direction, types, versioning) 63 | end 64 | 65 | defp do_run_types(direction, [_type | types], versioning) do 66 | do_run_types(direction, types, versioning) 67 | end 68 | 69 | defp do_run_changes(_direction, [], versioning) do 70 | versioning 71 | end 72 | 73 | defp do_run_changes(direction, changes, versioning) do 74 | Enum.reduce(changes, versioning, fn {change, opts}, versioning -> 75 | apply(Versioning.Change, direction, [versioning, change, opts]) 76 | end) 77 | end 78 | 79 | defp parse_versions(adapter, versioning) do 80 | with {:ok, target} <- Versioning.Adapter.parse(adapter, versioning.target), 81 | {:ok, current} <- Versioning.Adapter.parse(adapter, versioning.current) do 82 | {:ok, target, current} 83 | else 84 | _ -> {:error, %VersioningError{message: "invalid versions provided."}} 85 | end 86 | end 87 | 88 | defp locate_current(schema, version) do 89 | schema 90 | |> Enum.drop_while(fn {v, _, _} -> version != v end) 91 | |> case do 92 | [] -> 93 | {:error, %VersioningError{message: "current version not found in schema."}} 94 | 95 | [_ | schema] -> 96 | {:ok, schema} 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/versioning/view.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Phoenix) do 2 | defmodule Versioning.View do 3 | @moduledoc """ 4 | A set of functions used with `Phoenix` views. 5 | 6 | Typically, this module should be imported into your view modules. In a normal 7 | phoenix application, this can usually be done with the following: 8 | 9 | defmodule YourAppWeb do 10 | # ... 11 | 12 | def view do 13 | quote do 14 | use Phoenix.View, root: "lib/your_app_web/templates", namespace: "web" 15 | 16 | # ... 17 | 18 | import Versioning.View 19 | 20 | # ... 21 | end 22 | end 23 | end 24 | 25 | Please see the documentation at `Phoenix.View` for details on how to set up 26 | a typical view. 27 | 28 | In places that you would use `Phoenix.View.render_one/4`, this module provides 29 | `render_version/6`. In places that you would use `Phoenix.View.render_many/4`, 30 | this module provides `render_versions/6`. 31 | 32 | In order to use these functions, you must already have applied the schema and 33 | requested version to the conn. This is typically done with `Versioning.Plug` 34 | or through the helpers available in `Versioning.Controller`. 35 | 36 | ## Example 37 | 38 | Below is an example of how to use versioning in a typical view: 39 | 40 | defmodule YourApp.UserView do 41 | use YourApp.View 42 | 43 | def render("index.json", %{conn: conn, users: users}) do 44 | %{ 45 | "users" => render_versions(conn, users, "User", UserView, "user.json"), 46 | } 47 | end 48 | 49 | def render("show.json", %{conn: conn, users: users}) do 50 | %{ 51 | "user" => render_version(conn, users, "User", UserView, "user.json"), 52 | } 53 | end 54 | 55 | def render("user.json", %{user: user}) do 56 | %{"name" => user.name, "address" => user.address} 57 | end 58 | end 59 | 60 | A typical call, such as: 61 | 62 | render_many(users, UserView, "user.json") 63 | 64 | Is replaced by the following: 65 | 66 | render_versions(conn, users, "User", UserView, "user.json") 67 | 68 | In order to render versions of our data, we must pass the conn struct, our 69 | data to be versioned, the type the data represents in our schema, the view 70 | module to use, the template to use, as well as an additional assigns. 71 | 72 | The contents of the "user.json" template represent the latest version of your 73 | data. They will be run through your versioning schema to the version requested 74 | by the user. The output returned by your schema is what will be finally 75 | rendered. 76 | 77 | """ 78 | 79 | @doc """ 80 | Renders a versioned collection. 81 | 82 | A collection is any enumerable of structs. This function returns the 83 | rendered versioned collection in a list: 84 | 85 | render_versions(conn, users, "User", UserView, "show.json") 86 | 87 | Under the hood, this will render each item using `Phoenix.View.render/3` - so 88 | the latest version of the data should be represented in your view using typical 89 | view standards. 90 | 91 | After the data has been rendered, it will be passed to your schema and 92 | versioned to the version that has been requested. 93 | 94 | """ 95 | @spec render_versions(Plug.Conn.t(), list(), binary(), module(), binary(), map()) :: [any()] 96 | def render_versions(conn, collection, type, view, template, assigns \\ %{}) do 97 | Enum.map(collection, fn resource -> 98 | data = Phoenix.View.render_one(resource, view, template, assigns) 99 | do_versioning(conn, data, type) 100 | end) 101 | end 102 | 103 | @doc """ 104 | Renders a single versioned item if not nil. 105 | 106 | render_version(conn, user, "User", UserView, "show.json") 107 | 108 | This require 109 | 110 | Under the hood, this will render the item using `Phoenix.View.render/3` - so 111 | the latest version of the data should be represented in your view using typical 112 | view standards. 113 | 114 | After the data has been rendered, it will be passed to your schema and 115 | versioned to requested target version. 116 | 117 | """ 118 | @spec render_version(Plug.Conn.t(), any(), binary(), module(), binary(), map()) :: any() 119 | def render_version(conn, resource, type, view, template, assigns \\ %{}) 120 | def render_version(_conn, _type, nil, _view, _template, _assigns), do: nil 121 | 122 | def render_version(conn, resource, type, view, template, assigns) do 123 | data = Phoenix.View.render_one(resource, view, template, assigns) 124 | do_versioning(conn, data, type) 125 | end 126 | 127 | defp do_versioning(conn, data, type) do 128 | {schema, current, target} = get_versioning(conn) 129 | versioning = Versioning.new(data, current, target, type) 130 | 131 | case schema.run(versioning) do 132 | {:ok, versioning} -> versioning.data 133 | {:error, error} -> raise error 134 | end 135 | end 136 | 137 | defp get_versioning(conn) do 138 | schema = Versioning.Controller.fetch_schema!(conn) 139 | current = schema.__schema__(:latest, :string) 140 | target = Versioning.Controller.fetch_version!(conn) 141 | 142 | {schema, current, target} 143 | end 144 | end 145 | end 146 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Versioning.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.4.1" 5 | 6 | def project do 7 | [ 8 | app: :versioning, 9 | version: @version, 10 | elixir: "~> 1.7", 11 | elixirc_paths: elixirc_paths(Mix.env()), 12 | start_permanent: Mix.env() == :prod, 13 | deps: deps(), 14 | description: description(), 15 | package: package(), 16 | name: "Versioning", 17 | docs: docs() 18 | ] 19 | end 20 | 21 | # Run "mix help compile.app" to learn about applications. 22 | def application do 23 | [ 24 | extra_applications: [:logger] 25 | ] 26 | end 27 | 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | defp description do 32 | """ 33 | Versioning provides a way for API's to remain backward compatible without the headache. 34 | """ 35 | end 36 | 37 | defp package do 38 | [ 39 | files: ["lib", "mix.exs", "README*"], 40 | maintainers: ["Nicholas Sweeting"], 41 | licenses: ["MIT"], 42 | links: %{"GitHub" => "https://github.com/nsweeting/versioning"} 43 | ] 44 | end 45 | 46 | defp docs do 47 | [ 48 | main: "Versioning", 49 | source_ref: "v#{@version}", 50 | canonical: "http://hexdocs.pm/versioning", 51 | main: "readme", 52 | extras: [ 53 | "README.md", 54 | "guides/Getting Started.md", 55 | "guides/Phoenix Usage.md" 56 | ], 57 | source_url: "https://github.com/nsweeting/versioning", 58 | source_url_pattern: "https://github.com/nsweeting/versioning/blob/master/%{path}#L%{line}", 59 | groups_for_modules: [ 60 | Adapters: [ 61 | Versioning.Adapter, 62 | Versioning.Adapter.Semantic, 63 | Versioning.Adapter.Date 64 | ], 65 | Changelogs: [ 66 | Versioning.Changelog, 67 | Versioning.Changelog.Formatter, 68 | Versioning.Changelog.Markdown 69 | ], 70 | Phoenix: [ 71 | Versioning.Controller, 72 | Versioning.Plug, 73 | Versioning.View 74 | ] 75 | ] 76 | ] 77 | end 78 | 79 | # Run "mix help deps" to learn about dependencies. 80 | defp deps do 81 | [ 82 | {:phoenix, "~> 1.5", optional: true}, 83 | {:poison, "~> 3.1", optional: true}, 84 | {:ex_doc, "~> 0.23", only: :dev, runtime: false} 85 | ] 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm", "e3be2bc3ae67781db529b80aa7e7c49904a988596e2dbff897425b48b3581161"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, 4 | "ex_doc": {:hex, :ex_doc, "0.23.0", "a069bc9b0bf8efe323ecde8c0d62afc13d308b1fa3d228b65bca5cf8703a529d", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f5e2c4702468b2fd11b10d39416ddadd2fcdd173ba2a0285ebd92c39827a5a16"}, 5 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 6 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.0", "98312c9f0d3730fde4049985a1105da5155bfe5c11e47bdc7406d88e01e4219b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "75ffa34ab1056b7e24844c90bfc62aaf6f3a37a15faa76b07bc5eba27e4a8b4a"}, 7 | "mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 9 | "phoenix": {:hex, :phoenix, "1.5.7", "2923bb3af924f184459fe4fa4b100bd25fa6468e69b2803dfae82698269aa5e0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "774cd64417c5a3788414fdbb2be2eb9bcd0c048d9e6ad11a0c1fd67b7c0d0978"}, 10 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 11 | "phx_new": {:hex, :phx_new, "1.4.0", "4e4e25cbdc9eebbd1d2cb439e634884cf75b39a9d8bb93de0d7ff19890fb2048", [:mix], [], "hexpm"}, 12 | "plug": {:hex, :plug, "1.11.0", "f17217525597628298998bc3baed9f8ea1fa3f1160aa9871aee6df47a6e4d38e", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2d9c633f0499f9dc5c2fd069161af4e2e7756890b81adcbb2ceaa074e8308876"}, 13 | "plug_crypto": {:hex, :plug_crypto, "1.2.0", "1cb20793aa63a6c619dd18bb33d7a3aa94818e5fd39ad357051a67f26dfa2df6", [:mix], [], "hexpm", "a48b538ae8bf381ffac344520755f3007cc10bd8e90b240af98ea29b69683fc2"}, 14 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, 15 | "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, 16 | } 17 | -------------------------------------------------------------------------------- /test/support/changes.ex: -------------------------------------------------------------------------------- 1 | for x <- 1..15 do 2 | contents = 3 | quote do 4 | use Versioning.Change 5 | 6 | @desc to_string(unquote(x)) 7 | 8 | @impl true 9 | def up(versioning, _opts) do 10 | {up, versioning} = Versioning.pop_data(versioning, "up", []) 11 | Versioning.put_data(versioning, "up", up ++ [unquote(x)]) 12 | end 13 | 14 | @impl true 15 | def down(versioning, _opts) do 16 | {down, versioning} = Versioning.pop_data(versioning, "down", []) 17 | Versioning.put_data(versioning, "down", down ++ [unquote(x)]) 18 | end 19 | end 20 | 21 | Module.create(:"Elixir.TestChange#{x}", contents, Macro.Env.location(__ENV__)) 22 | end 23 | 24 | defmodule(Foo, do: defstruct(down: [], up: [])) 25 | defmodule(Bar, do: defstruct(down: [], up: [])) 26 | -------------------------------------------------------------------------------- /test/support/schemas.ex: -------------------------------------------------------------------------------- 1 | defmodule MySchema do 2 | use Versioning.Schema, adapter: Versioning.Adapter.Semantic 3 | 4 | version("2.0.1", do: []) 5 | 6 | version "2.0.0" do 7 | type "All!" do 8 | change(TestChange15) 9 | change(TestChange14) 10 | end 11 | 12 | type "Foo" do 13 | change(TestChange13) 14 | change(TestChange12) 15 | end 16 | end 17 | 18 | version "1.1.1" do 19 | type "All!" do 20 | change(TestChange11) 21 | change(TestChange10) 22 | end 23 | end 24 | 25 | version "1.1.0" do 26 | type "Bar" do 27 | change(TestChange9) 28 | change(TestChange8) 29 | end 30 | end 31 | 32 | version "1.0.2" do 33 | type "Foo" do 34 | change(TestChange7) 35 | change(TestChange6) 36 | end 37 | end 38 | 39 | version "1.0.1" do 40 | type "All!" do 41 | change(TestChange5) 42 | change(TestChange4) 43 | end 44 | 45 | type "Bar" do 46 | change(TestChange3) 47 | change(TestChange2) 48 | end 49 | 50 | type "Foo" do 51 | change(TestChange1) 52 | end 53 | end 54 | 55 | version("1.0.0", do: []) 56 | end 57 | 58 | defmodule MySchemaLatest do 59 | use Versioning.Schema, adapter: Versioning.Adapter.Semantic 60 | 61 | @latest "1.0.0" 62 | 63 | version("1.0.1", do: []) 64 | 65 | version("1.0.0", do: []) 66 | end 67 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.ensure_all_started(:phoenix) 2 | 3 | ExUnit.start() 4 | -------------------------------------------------------------------------------- /test/versioning/adapters/date_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Versioning.Adapter.DateTest do 2 | use ExUnit.Case 3 | 4 | alias Versioning.Adapter.Date, as: DateAdapter 5 | 6 | describe "parse/1" do 7 | test "will parse string dates" do 8 | assert {:ok, ~D[2019-01-01]} = DateAdapter.parse("2019-01-01") 9 | end 10 | 11 | test "will parse date dates" do 12 | assert {:ok, ~D[2019-01-01]} = DateAdapter.parse(~D[2019-01-01]) 13 | end 14 | 15 | test "will return :error on bad values" do 16 | assert :error = DateAdapter.parse(1) 17 | assert :error = DateAdapter.parse("1") 18 | assert :error = DateAdapter.parse(:foo) 19 | assert :error = DateAdapter.parse(%{}) 20 | end 21 | end 22 | 23 | describe "compare/2" do 24 | test "will return :eq if two versions are equal for strings" do 25 | assert :eq = DateAdapter.compare("2019-01-01", "2019-01-01") 26 | end 27 | 28 | test "will return :eq if two versions are equal for dates" do 29 | assert :eq = DateAdapter.compare(~D[2019-01-01], ~D[2019-01-01]) 30 | end 31 | 32 | test "will return :gt if version1 is greater than version2 for strings" do 33 | assert :gt = DateAdapter.compare("2019-01-02", "2019-01-01") 34 | end 35 | 36 | test "will return :gt if version1 is greater than version2 for dates" do 37 | assert :gt = DateAdapter.compare(~D[2019-01-02], ~D[2019-01-01]) 38 | end 39 | 40 | test "will return :lt if version1 is less than than version2 for strings" do 41 | assert :lt = DateAdapter.compare("2019-01-01", "2019-01-02") 42 | end 43 | 44 | test "will return :lt if version1 is less than than version2 for dates" do 45 | assert :lt = DateAdapter.compare(~D[2019-01-01], ~D[2019-01-02]) 46 | end 47 | 48 | test "will return :error if version1 is a bad value" do 49 | assert :error = DateAdapter.compare(1, "2019-01-02") 50 | end 51 | 52 | test "will return :error if version2 is a bad value" do 53 | assert :error = DateAdapter.compare("2019-01-01", 1) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/versioning/adapters/semantic_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Versioning.Adapter.SemanticTest do 2 | use ExUnit.Case 3 | 4 | alias Versioning.Adapter.Semantic, as: SemanticAdapter 5 | 6 | describe "parse/1" do 7 | test "will parse string versions" do 8 | assert {:ok, %Version{major: 1, minor: 0, patch: 0}} = SemanticAdapter.parse("1.0.0") 9 | end 10 | 11 | test "will parse version versions" do 12 | assert {:ok, %Version{major: 1, minor: 0, patch: 0}} = 13 | SemanticAdapter.parse(%Version{major: 1, minor: 0, patch: 0}) 14 | end 15 | 16 | test "will return :error on bad values" do 17 | assert :error = SemanticAdapter.parse(1) 18 | assert :error = SemanticAdapter.parse("1") 19 | assert :error = SemanticAdapter.parse(:foo) 20 | assert :error = SemanticAdapter.parse(%{}) 21 | end 22 | end 23 | 24 | describe "compare/2" do 25 | test "will return :eq if two versions are equal for strings" do 26 | assert :eq = SemanticAdapter.compare("1.0.0", "1.0.0") 27 | end 28 | 29 | test "will return :eq if two versions are equal for dates" do 30 | assert :eq = 31 | SemanticAdapter.compare(%Version{major: 1, minor: 0, patch: 0}, %Version{ 32 | major: 1, 33 | minor: 0, 34 | patch: 0 35 | }) 36 | end 37 | 38 | test "will return :gt if version1 is greater than version2 for strings" do 39 | assert :gt = SemanticAdapter.compare("1.0.1", "1.0.0") 40 | end 41 | 42 | test "will return :gt if version1 is greater than version2 for dates" do 43 | assert :gt = 44 | SemanticAdapter.compare(%Version{major: 1, minor: 0, patch: 1}, %Version{ 45 | major: 1, 46 | minor: 0, 47 | patch: 0 48 | }) 49 | end 50 | 51 | test "will return :lt if version1 is less than than version2 for strings" do 52 | assert :lt = SemanticAdapter.compare("1.0.0", "1.0.1") 53 | end 54 | 55 | test "will return :lt if version1 is less than than version2 for dates" do 56 | assert :lt = 57 | SemanticAdapter.compare(%Version{major: 1, minor: 0, patch: 0}, %Version{ 58 | major: 1, 59 | minor: 0, 60 | patch: 1 61 | }) 62 | end 63 | 64 | test "will return :error if version1 is a bad value" do 65 | assert :error = SemanticAdapter.compare(1, "1.0.0") 66 | end 67 | 68 | test "will return :error if version2 is a bad value" do 69 | assert :error = SemanticAdapter.compare("1.0.0", 1) 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/versioning/changelog_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Versioning.ChangelogTest do 2 | use ExUnit.Case 3 | 4 | alias Versioning.Changelog 5 | 6 | describe "build/2" do 7 | test "will return a changelog map of the schema" do 8 | changelog = Changelog.build(MySchema) 9 | 10 | assert changelog == [ 11 | %{changes: [], version: "2.0.1"}, 12 | %{ 13 | changes: [ 14 | %{descriptions: ["15", "14"], type: "All!"}, 15 | %{descriptions: ["13", "12"], type: "Foo"} 16 | ], 17 | version: "2.0.0" 18 | }, 19 | %{changes: [%{descriptions: ["11", "10"], type: "All!"}], version: "1.1.1"}, 20 | %{changes: [%{descriptions: ["9", "8"], type: "Bar"}], version: "1.1.0"}, 21 | %{changes: [%{descriptions: ["7", "6"], type: "Foo"}], version: "1.0.2"}, 22 | %{ 23 | changes: [ 24 | %{descriptions: ["3", "2"], type: "Bar"}, 25 | %{descriptions: ["5", "4"], type: "All!"}, 26 | %{descriptions: ["1"], type: "Foo"} 27 | ], 28 | version: "1.0.1" 29 | }, 30 | %{changes: [], version: "1.0.0"} 31 | ] 32 | end 33 | 34 | test "will return a specific version of the changelog with :version option" do 35 | changelog = Changelog.build(MySchema, version: "1.0.1") 36 | 37 | assert changelog == %{ 38 | changes: [ 39 | %{descriptions: ["3", "2"], type: "Bar"}, 40 | %{descriptions: ["5", "4"], type: "All!"}, 41 | %{descriptions: ["1"], type: "Foo"} 42 | ], 43 | version: "1.0.1" 44 | } 45 | end 46 | 47 | test "will raise a Versioning.ChangelogError when given an invalid version" do 48 | assert_raise(Versioning.ChangelogError, fn -> 49 | Changelog.build(MySchema, version: "foo") 50 | end) 51 | end 52 | 53 | test "will return a specific version and type of the changelog with :version and :type option" do 54 | changelog = Changelog.build(MySchema, version: "1.0.1", type: "All!") 55 | 56 | assert changelog == %{descriptions: ["5", "4"], type: "All!"} 57 | end 58 | 59 | test "will raise a Versioning.ChangelogError when give a :type without a :version" do 60 | assert_raise(Versioning.ChangelogError, fn -> 61 | Changelog.build(MySchema, type: "All!") 62 | end) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/versioning/controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Versioning.ControllerTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | describe "put_schema/2" do 6 | test "will put the schema in the conn prviate map" do 7 | conn = conn(:get, "/") 8 | 9 | assert conn.private[:versioning_schema] == nil 10 | 11 | conn = Versioning.Controller.put_schema(conn, MySchema) 12 | 13 | assert conn.private.versioning_schema == MySchema 14 | end 15 | end 16 | 17 | describe "fetch_schema/1" do 18 | test "will fetch the schema from the conn" do 19 | conn = conn(:get, "/") 20 | 21 | conn = Versioning.Controller.put_schema(conn, MySchema) 22 | 23 | assert {:ok, MySchema} = Versioning.Controller.fetch_schema(conn) 24 | end 25 | 26 | test "will return :error if schema does not exist in conn" do 27 | conn = conn(:get, "/") 28 | 29 | assert :error = Versioning.Controller.fetch_schema(conn) 30 | end 31 | end 32 | 33 | describe "fetch_schema!/1" do 34 | test "will fetch the schema from the conn" do 35 | conn = conn(:get, "/") 36 | 37 | conn = Versioning.Controller.put_schema(conn, MySchema) 38 | 39 | assert MySchema = Versioning.Controller.fetch_schema!(conn) 40 | end 41 | 42 | test "will raise a Versioning.MissingSchemaError if no schema exists" do 43 | conn = conn(:get, "/") 44 | 45 | assert_raise Versioning.MissingSchemaError, fn -> 46 | Versioning.Controller.fetch_schema!(conn) 47 | end 48 | end 49 | end 50 | 51 | describe "fetch_version/1" do 52 | test "will fetch the version from the conn" do 53 | conn = conn(:get, "/") 54 | 55 | conn = Versioning.Controller.put_version(conn, "1.0.0") 56 | 57 | assert {:ok, "1.0.0"} = Versioning.Controller.fetch_version(conn) 58 | end 59 | 60 | test "will return :error if version does not exist in conn" do 61 | conn = conn(:get, "/") 62 | 63 | assert :error = Versioning.Controller.fetch_version(conn) 64 | end 65 | end 66 | 67 | describe "fetch_version!/1" do 68 | test "will fetch the version from the conn" do 69 | conn = conn(:get, "/") 70 | 71 | conn = Versioning.Controller.put_version(conn, "1.0.0") 72 | 73 | assert "1.0.0" = Versioning.Controller.fetch_version!(conn) 74 | end 75 | 76 | test "will raise a Versioning.MissingVersionError if no schema exists" do 77 | conn = conn(:get, "/") 78 | 79 | assert_raise Versioning.MissingVersionError, fn -> 80 | Versioning.Controller.fetch_version!(conn) 81 | end 82 | end 83 | end 84 | 85 | describe "apply_version/3" do 86 | test "will apply the version using a specificied header" do 87 | conn = conn(:get, "/") |> put_req_header("x-version", "1.0.0") 88 | conn = Versioning.Controller.apply_version(conn, "x-version") 89 | 90 | assert conn.private.versioning_version == "1.0.0" 91 | end 92 | 93 | test "will apply the version using a fallback module and function" do 94 | defmodule Fallback do 95 | def call(_conn) do 96 | "1.0.0" 97 | end 98 | end 99 | 100 | conn = conn(:get, "/") 101 | conn = Versioning.Controller.apply_version(conn, "x-version", {Fallback, :call}) 102 | 103 | assert conn.private.versioning_version == "1.0.0" 104 | end 105 | 106 | test "will apply the version using the latest if no header is present" do 107 | conn = 108 | conn(:get, "/") 109 | |> Versioning.Controller.put_schema(MySchema) 110 | |> Versioning.Controller.apply_version() 111 | 112 | assert conn.private.versioning_version == "2.0.1" 113 | end 114 | end 115 | 116 | describe "params_version/3" do 117 | test "will run the params through the schema" do 118 | conn = 119 | conn(:get, "/") 120 | |> Versioning.Controller.put_schema(MySchema) 121 | |> Versioning.Controller.put_version("1.0.0") 122 | 123 | assert {:ok, params} = Versioning.Controller.params_version(conn, %{}, "Foo") 124 | assert params == %{"up" => [1, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15]} 125 | end 126 | 127 | test "will return an error tuple if the version doesnt exist" do 128 | conn = 129 | conn(:get, "/") 130 | |> Versioning.Controller.put_schema(MySchema) 131 | |> Versioning.Controller.put_version("0.5.0") 132 | 133 | assert {:error, :bad_version} = Versioning.Controller.params_version(conn, %{}, "Foo") 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /test/versioning/formatter/markdown_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Versioning.Changelog.MarkdownTest do 2 | @moduledoc """ 3 | Better tests required... 4 | """ 5 | 6 | use ExUnit.Case 7 | 8 | alias Versioning.Changelog 9 | alias Versioning.Changelog.Markdown 10 | 11 | describe "format/1" do 12 | test "will format a full changelog" do 13 | changelog = Changelog.build(MySchema, formatter: Markdown) 14 | assert is_binary(changelog) 15 | end 16 | 17 | test "will format a specific version" do 18 | changelog = Changelog.build(MySchema, version: "1.0.1", formatter: Markdown) 19 | assert is_binary(changelog) 20 | end 21 | 22 | test "will format a specific version and type" do 23 | changelog = Changelog.build(MySchema, version: "1.0.1", type: "All!", formatter: Markdown) 24 | 25 | assert is_binary(changelog) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/versioning/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Versioning.PlugTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | describe "init/1" do 6 | test "will raise an error if a schema is not provided" do 7 | assert_raise KeyError, fn -> 8 | Versioning.Plug.init(type: "Foo") 9 | end 10 | end 11 | end 12 | 13 | describe "call/2" do 14 | test "will put the schema in the conn private" do 15 | opts = Versioning.Plug.init(schema: MySchema) 16 | 17 | conn = 18 | conn(:get, "/") 19 | |> Versioning.Plug.call(opts) 20 | 21 | assert conn.private.versioning_schema == MySchema 22 | end 23 | 24 | test "will put the version request to the conn private map" do 25 | opts = Versioning.Plug.init(schema: MySchema) 26 | 27 | conn = 28 | conn(:get, "/") 29 | |> put_req_header("x-api-version", "1.0.0") 30 | |> Versioning.Plug.call(opts) 31 | 32 | assert conn.private.versioning_version == "1.0.0" 33 | end 34 | 35 | test "will use custom headers for the version request" do 36 | opts = Versioning.Plug.init(schema: MySchema, header: "x-version") 37 | 38 | conn = 39 | conn(:get, "/") 40 | |> put_req_header("x-version", "1.0.0") 41 | |> Versioning.Plug.call(opts) 42 | 43 | assert conn.private.versioning_version == "1.0.0" 44 | end 45 | 46 | test "will default to the latest version if no header or fallback is present" do 47 | opts = Versioning.Plug.init(schema: MySchema) 48 | 49 | conn = 50 | conn(:get, "/") 51 | |> Versioning.Plug.call(opts) 52 | 53 | assert conn.private.versioning_version == "2.0.1" 54 | end 55 | 56 | test "will use the fallback module function if provided" do 57 | defmodule Fallback do 58 | def call(_conn) do 59 | "1.0.0" 60 | end 61 | end 62 | 63 | opts = Versioning.Plug.init(schema: MySchema, fallback: {Fallback, :call}) 64 | 65 | conn = 66 | conn(:get, "/") 67 | |> Versioning.Plug.call(opts) 68 | 69 | assert conn.private.versioning_version == "1.0.0" 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/versioning/schema_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Versioning.SchemaTest do 2 | use ExUnit.Case 3 | 4 | describe "run/1" do 5 | test "will run all Foo changes from version 2.0.1 to 1.0.0" do 6 | versioning = Versioning.new(%Foo{}, "2.0.1", "1.0.0") 7 | 8 | assert {:ok, versioning} = MySchema.run(versioning) 9 | assert versioning.data["down"] == [15, 14, 13, 12, 11, 10, 7, 6, 5, 4, 1] 10 | assert versioning.data["up"] == [] 11 | end 12 | 13 | test "will run all Foo changes from version 2.0.1 to 1.0.1" do 14 | versioning = Versioning.new(%Foo{}, "2.0.1", "1.0.1") 15 | 16 | assert {:ok, versioning} = MySchema.run(versioning) 17 | assert versioning.data["down"] == [15, 14, 13, 12, 11, 10, 7, 6, 5, 4, 1] 18 | assert versioning.data["up"] == [] 19 | end 20 | 21 | test "will run all Foo changes from version 2.0.1 to 1.0.2" do 22 | versioning = Versioning.new(%Foo{}, "2.0.1", "1.0.2") 23 | 24 | assert {:ok, versioning} = MySchema.run(versioning) 25 | assert versioning.data["down"] == [15, 14, 13, 12, 11, 10, 7, 6] 26 | assert versioning.data["up"] == [] 27 | end 28 | 29 | test "will run all Foo changes from version 2.0.1 to 1.1.0" do 30 | versioning = Versioning.new(%Foo{}, "2.0.1", "1.1.0") 31 | 32 | assert {:ok, versioning} = MySchema.run(versioning) 33 | assert versioning.data["down"] == [15, 14, 13, 12, 11, 10] 34 | assert versioning.data["up"] == [] 35 | end 36 | 37 | test "will run all Foo changes from version 2.0.1 to 1.1.1" do 38 | versioning = Versioning.new(%Foo{}, "2.0.1", "1.1.1") 39 | 40 | assert {:ok, versioning} = MySchema.run(versioning) 41 | assert versioning.data["down"] == [15, 14, 13, 12, 11, 10] 42 | assert versioning.data["up"] == [] 43 | end 44 | 45 | test "will run all Foo changes from version 2.0.1 to 2.0.0" do 46 | versioning = Versioning.new(%Foo{}, "2.0.1", "2.0.0") 47 | 48 | assert {:ok, versioning} = MySchema.run(versioning) 49 | assert versioning.data["down"] == [15, 14, 13, 12] 50 | assert versioning.data["up"] == [] 51 | end 52 | 53 | test "will run all Foo changes from version 2.0.1 to 2.0.1" do 54 | versioning = Versioning.new(%Foo{}, "2.0.1", "2.0.1") 55 | 56 | assert {:ok, versioning} = MySchema.run(versioning) 57 | assert versioning.data["down"] == [] 58 | assert versioning.data["up"] == [] 59 | end 60 | 61 | test "will run all Foo changes from version 2.0.0 to 1.0.0" do 62 | versioning = Versioning.new(%Foo{}, "2.0.0", "1.0.0") 63 | 64 | assert {:ok, versioning} = MySchema.run(versioning) 65 | assert versioning.data["down"] == [11, 10, 7, 6, 5, 4, 1] 66 | assert versioning.data["up"] == [] 67 | end 68 | 69 | test "will run all Foo changes from version 1.1.1 to 1.0.0" do 70 | versioning = Versioning.new(%Foo{}, "1.1.1", "1.0.0") 71 | 72 | assert {:ok, versioning} = MySchema.run(versioning) 73 | assert versioning.data["down"] == [7, 6, 5, 4, 1] 74 | assert versioning.data["up"] == [] 75 | end 76 | 77 | test "will run all Foo changes from version 1.1.0 to 1.0.0" do 78 | versioning = Versioning.new(%Foo{}, "1.1.0", "1.0.0") 79 | 80 | assert {:ok, versioning} = MySchema.run(versioning) 81 | assert versioning.data["down"] == [7, 6, 5, 4, 1] 82 | assert versioning.data["up"] == [] 83 | end 84 | 85 | test "will run all Foo changes from version 1.0.2 to 1.0.0" do 86 | versioning = Versioning.new(%Foo{}, "1.0.2", "1.0.0") 87 | 88 | assert {:ok, versioning} = MySchema.run(versioning) 89 | assert versioning.data["down"] == [5, 4, 1] 90 | assert versioning.data["up"] == [] 91 | end 92 | 93 | test "will run all Foo changes from version 1.0.1 to 1.0.0" do 94 | versioning = Versioning.new(%Foo{}, "1.0.1", "1.0.0") 95 | 96 | assert {:ok, versioning} = MySchema.run(versioning) 97 | assert versioning.data["down"] == [] 98 | assert versioning.data["up"] == [] 99 | end 100 | 101 | test "will run all Bar changes from version 2.0.1 to 1.0.0" do 102 | versioning = Versioning.new(%Bar{}, "2.0.1", "1.0.0") 103 | 104 | assert {:ok, versioning} = MySchema.run(versioning) 105 | assert versioning.data["down"] == [15, 14, 11, 10, 9, 8, 3, 2, 5, 4] 106 | assert versioning.data["up"] == [] 107 | end 108 | 109 | test "will run all Foo changes from version 1.0.0 to 2.0.1" do 110 | versioning = Versioning.new(%Foo{}, "1.0.0", "2.0.1") 111 | 112 | assert {:ok, versioning} = MySchema.run(versioning) 113 | assert versioning.data["down"] == [] 114 | assert versioning.data["up"] == [1, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15] 115 | end 116 | 117 | test "will run all Foo changes from version 1.0.1 to 2.0.1" do 118 | versioning = Versioning.new(%Foo{}, "1.0.1", "2.0.1") 119 | 120 | assert {:ok, versioning} = MySchema.run(versioning) 121 | assert versioning.data["down"] == [] 122 | assert versioning.data["up"] == [6, 7, 10, 11, 12, 13, 14, 15] 123 | end 124 | 125 | test "will run all Foo changes from version 1.0.2 to 2.0.1" do 126 | versioning = Versioning.new(%Foo{}, "1.0.2", "2.0.1") 127 | 128 | assert {:ok, versioning} = MySchema.run(versioning) 129 | assert versioning.data["down"] == [] 130 | assert versioning.data["up"] == [10, 11, 12, 13, 14, 15] 131 | end 132 | 133 | test "will run all Foo changes from version 1.1.0 to 2.0.1" do 134 | versioning = Versioning.new(%Foo{}, "1.1.0", "2.0.1") 135 | 136 | assert {:ok, versioning} = MySchema.run(versioning) 137 | assert versioning.data["down"] == [] 138 | assert versioning.data["up"] == [10, 11, 12, 13, 14, 15] 139 | end 140 | 141 | test "will run all Foo changes from version 1.1.1 to 2.0.1" do 142 | versioning = Versioning.new(%Foo{}, "1.1.1", "2.0.1") 143 | 144 | assert {:ok, versioning} = MySchema.run(versioning) 145 | assert versioning.data["down"] == [] 146 | assert versioning.data["up"] == [12, 13, 14, 15] 147 | end 148 | 149 | test "will run all Foo changes from version 2.0.0 to 2.0.1" do 150 | versioning = Versioning.new(%Foo{}, "2.0.0", "2.0.1") 151 | 152 | assert {:ok, versioning} = MySchema.run(versioning) 153 | assert versioning.data["down"] == [] 154 | assert versioning.data["up"] == [] 155 | end 156 | 157 | test "will run all Foo changes from version 1.0.0 to 1.1.0" do 158 | versioning = Versioning.new(%Foo{}, "1.0.0", "1.1.1") 159 | 160 | assert {:ok, versioning} = MySchema.run(versioning) 161 | assert versioning.data["down"] == [] 162 | assert versioning.data["up"] == [1, 4, 5, 6, 7, 10, 11] 163 | end 164 | 165 | test "will run all Bar changes from version 1.0.0 to 2.0.1" do 166 | versioning = Versioning.new(%Bar{}, "1.0.0", "2.0.1") 167 | 168 | assert {:ok, versioning} = MySchema.run(versioning) 169 | assert versioning.data["down"] == [] 170 | assert versioning.data["up"] == [4, 5, 2, 3, 8, 9, 10, 11, 14, 15] 171 | end 172 | 173 | test "will return a Versioning.ExecutionError if current version is not matched" do 174 | versioning = Versioning.new(%Foo{}, "3.0.0", "1.0.0") 175 | 176 | assert {:error, %VersioningError{}} = MySchema.run(versioning) 177 | end 178 | 179 | test "will return a Versioning.ExecutionError if target version is not matched" do 180 | versioning = Versioning.new(%Foo{}, "2.0.1", "3.0.0") 181 | 182 | assert {:error, %VersioningError{}} = MySchema.run(versioning) 183 | end 184 | 185 | test "will return a Versioning.ExecutionError if target version is not parseable" do 186 | versioning = Versioning.new(%Foo{}, "foo", "1.0.0") 187 | 188 | assert {:error, %VersioningError{}} = MySchema.run(versioning) 189 | end 190 | 191 | test "will return a Versioning.ExecutionError if current version is not parseable" do 192 | versioning = Versioning.new(%Foo{}, "1.0.0", "foo") 193 | 194 | assert {:error, %VersioningError{}} = MySchema.run(versioning) 195 | end 196 | end 197 | 198 | # Metadata 199 | 200 | test "schema metadata" do 201 | assert %Version{major: 2, minor: 0, patch: 1} = MySchema.__schema__(:latest, :parsed) 202 | assert "2.0.1" = MySchema.__schema__(:latest, :string) 203 | end 204 | 205 | test "schema metadata with @latest attribute" do 206 | assert %Version{major: 1, minor: 0, patch: 0} = MySchemaLatest.__schema__(:latest, :parsed) 207 | assert "1.0.0" = MySchemaLatest.__schema__(:latest, :string) 208 | end 209 | 210 | # Errors 211 | 212 | test "duplicate versions" do 213 | assert_raise Versioning.CompileError, fn -> 214 | defmodule MySchemaWithDuplicateVersions do 215 | use Versioning.Schema, adapter: Versioning.Adapter.Semantic 216 | 217 | version("1.0.0", do: []) 218 | 219 | version("1.0.0", do: []) 220 | end 221 | end 222 | end 223 | 224 | test "invalid version order" do 225 | assert_raise Versioning.CompileError, fn -> 226 | defmodule MySchemaWithInvalidVersionOrder do 227 | use Versioning.Schema, adapter: Versioning.Adapter.Semantic 228 | 229 | version("1.0.0", do: []) 230 | 231 | version("1.0.1", do: []) 232 | end 233 | end 234 | end 235 | 236 | test "duplicate types" do 237 | assert_raise Versioning.CompileError, fn -> 238 | defmodule MySchemaWithDuplicateTypes do 239 | use Versioning.Schema, adapter: Versioning.Adapter.Semantic 240 | 241 | version "1.0.0" do 242 | type("Foo", do: []) 243 | type("Foo", do: []) 244 | end 245 | end 246 | end 247 | end 248 | 249 | test "invalid type format" do 250 | assert_raise Versioning.CompileError, fn -> 251 | defmodule MySchemaWithInvalidType do 252 | use Versioning.Schema, adapter: Versioning.Adapter.Semantic 253 | 254 | version "1.0.0" do 255 | type(Foo, do: []) 256 | end 257 | end 258 | end 259 | end 260 | 261 | test "invalid change module" do 262 | assert_raise Versioning.CompileError, fn -> 263 | defmodule MySchemaWithInvalidChangeModule do 264 | use Versioning.Schema, adapter: Versioning.Adapter.Semantic 265 | 266 | version "1.0.0" do 267 | type "Foo" do 268 | change(BadChange) 269 | end 270 | end 271 | end 272 | end 273 | end 274 | 275 | test "invalid @latest attirbute" do 276 | assert_raise Versioning.CompileError, fn -> 277 | defmodule MySchemaWithInvalidLatest do 278 | use Versioning.Schema, adapter: Versioning.Adapter.Semantic 279 | 280 | @latest "1.0.1" 281 | 282 | version("1.0.0", do: []) 283 | end 284 | end 285 | end 286 | end 287 | -------------------------------------------------------------------------------- /test/versioning/view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Versioning.ViewTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | defmodule MyView do 6 | use Phoenix.View, root: "test/versioning" 7 | 8 | import Versioning.View 9 | 10 | def render("index.json", %{conn: conn, foos: foos}) do 11 | %{ 12 | foos: render_versions(conn, foos, "Foo", MyView, "foo.json") 13 | } 14 | end 15 | 16 | def render("show.json", %{conn: conn, foo: foo}) do 17 | %{ 18 | foo: render_version(conn, foo, "Foo", MyView, "foo.json") 19 | } 20 | end 21 | 22 | def render("foo.json", _assign) do 23 | %{ 24 | foo: "bar" 25 | } 26 | end 27 | end 28 | 29 | test "will render many versions" do 30 | conn = 31 | conn(:get, "/") 32 | |> Phoenix.Controller.put_view(MyView) 33 | |> Versioning.Controller.put_schema(MySchema) 34 | |> Versioning.Controller.put_version("1.0.0") 35 | 36 | conn = Phoenix.Controller.render(conn, "index.json", foos: [%{foo: "bar"}, %{foo: "bar"}]) 37 | body = Poison.decode!(conn.resp_body) 38 | 39 | assert Enum.count(body["foos"]) == 2 40 | assert List.first(body["foos"])["down"] == [15, 14, 13, 12, 11, 10, 7, 6, 5, 4, 1] 41 | assert List.last(body["foos"])["down"] == [15, 14, 13, 12, 11, 10, 7, 6, 5, 4, 1] 42 | end 43 | 44 | test "will render a version" do 45 | conn = 46 | conn(:get, "/") 47 | |> Phoenix.Controller.put_view(MyView) 48 | |> Versioning.Controller.put_schema(MySchema) 49 | |> Versioning.Controller.put_version("1.0.0") 50 | 51 | conn = Phoenix.Controller.render(conn, "show.json", foo: %{foo: "bar"}) 52 | body = Poison.decode!(conn.resp_body) 53 | 54 | assert body["foo"]["down"] == [15, 14, 13, 12, 11, 10, 7, 6, 5, 4, 1] 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/versioning_test.exs: -------------------------------------------------------------------------------- 1 | defmodule VersioningTest do 2 | use ExUnit.Case 3 | 4 | describe "new/4" do 5 | test "will create a new default versioning" do 6 | versioning = Versioning.new() 7 | 8 | assert versioning.data == %{} 9 | assert versioning.type == nil 10 | assert versioning.current == nil 11 | assert versioning.target == nil 12 | end 13 | 14 | test "will create a versioning from a map" do 15 | versioning = Versioning.new(%{}) 16 | 17 | assert versioning.data == %{} 18 | assert versioning.type == nil 19 | assert versioning.current == nil 20 | assert versioning.target == nil 21 | end 22 | 23 | test "will create a versioning from a struct" do 24 | versioning = Versioning.new(%Foo{}) 25 | 26 | assert versioning.data == %{"down" => [], "up" => []} 27 | assert versioning.type == "Foo" 28 | assert versioning.current == nil 29 | assert versioning.target == nil 30 | end 31 | 32 | test "will create a versioning from a struct with a current version" do 33 | versioning = Versioning.new(%Foo{}, "0.1.0") 34 | 35 | assert versioning.data == %{"down" => [], "up" => []} 36 | assert versioning.type == "Foo" 37 | assert versioning.current == "0.1.0" 38 | assert versioning.target == nil 39 | end 40 | 41 | test "will create a versioning from a struct with a current and target version" do 42 | versioning = Versioning.new(%Foo{}, "0.1.0", "1.0.0") 43 | 44 | assert versioning.data == %{"down" => [], "up" => []} 45 | assert versioning.type == "Foo" 46 | assert versioning.current == "0.1.0" 47 | assert versioning.target == "1.0.0" 48 | end 49 | 50 | test "will create a versioning from a struct with a current and target version and other type" do 51 | versioning = Versioning.new(%Foo{}, "0.1.0", "1.0.0", Bar) 52 | 53 | assert versioning.data == %{"down" => [], "up" => []} 54 | assert versioning.type == "Bar" 55 | assert versioning.current == "0.1.0" 56 | assert versioning.target == "1.0.0" 57 | end 58 | 59 | test "will create a versioning from a map with a current and target version and type" do 60 | versioning = Versioning.new(%{}, "0.1.0", "1.0.0", Bar) 61 | 62 | assert versioning.data == %{} 63 | assert versioning.type == "Bar" 64 | assert versioning.current == "0.1.0" 65 | assert versioning.target == "1.0.0" 66 | end 67 | end 68 | 69 | describe "put_current/2" do 70 | test "will put the current version" do 71 | versioning = Versioning.new(%Foo{}, "0.1.0", "1.0.0") 72 | 73 | assert versioning.current == "0.1.0" 74 | end 75 | end 76 | 77 | describe "put_target/2" do 78 | test "will put the target version" do 79 | versioning = Versioning.new(%Foo{}, "0.1.0", "1.0.0") 80 | 81 | assert versioning.target == "1.0.0" 82 | 83 | versioning = Versioning.put_target(versioning, "0.1.1") 84 | 85 | assert versioning.target == "0.1.1" 86 | end 87 | end 88 | 89 | describe "put_type/2" do 90 | test "will put the type of version" do 91 | versioning = Versioning.new(%Foo{}, "0.1.0", "1.0.0") 92 | 93 | assert versioning.type == "Foo" 94 | 95 | versioning = Versioning.put_type(versioning, Bar) 96 | 97 | assert versioning.type == "Bar" 98 | end 99 | end 100 | 101 | describe "assign/3" do 102 | test "will assign the key and value to the assigns" do 103 | versioning = Versioning.new(%Foo{}, "0.1.0", "1.0.0") 104 | 105 | assert versioning.assigns == %{} 106 | 107 | versioning = Versioning.assign(versioning, :foo, "bar") 108 | 109 | assert versioning.assigns == %{foo: "bar"} 110 | end 111 | end 112 | 113 | describe "pop_data/3" do 114 | test "will pop a value from the data and return a new versioning" do 115 | versioning = Versioning.new(%Foo{}, "0.1.0", "1.0.0") 116 | 117 | assert versioning.data == %{"down" => [], "up" => []} 118 | assert {[], versioning} = Versioning.pop_data(versioning, "down") 119 | assert versioning.data == %{"up" => []} 120 | end 121 | 122 | test "will return the default value if it doesnt exist in data" do 123 | versioning = Versioning.new(%Foo{}, "0.1.0", "1.0.0") 124 | 125 | assert versioning.data == %{"down" => [], "up" => []} 126 | assert {[], versioning} = Versioning.pop_data(versioning, "foo", []) 127 | assert versioning.data == %{"down" => [], "up" => []} 128 | end 129 | end 130 | 131 | describe "put_data/2" do 132 | test "will put full data into the versioning" do 133 | versioning = Versioning.new(%Foo{}, "0.1.0", "1.0.0") 134 | 135 | assert versioning.data == %{"down" => [], "up" => []} 136 | 137 | versioning = Versioning.put_data(versioning, %{bar: :baz}) 138 | 139 | assert versioning.data == %{"bar" => :baz} 140 | end 141 | end 142 | 143 | describe "put_data/3" do 144 | test "will put a key and value into the data" do 145 | versioning = Versioning.new(%Foo{}, "0.1.0", "1.0.0") 146 | 147 | assert versioning.data == %{"down" => [], "up" => []} 148 | 149 | versioning = Versioning.put_data(versioning, "foo", :bar) 150 | 151 | assert versioning.data == %{"down" => [], "up" => [], "foo" => :bar} 152 | end 153 | end 154 | end 155 | --------------------------------------------------------------------------------