Como gerenciar o versionamento da API REST com a primavera?

Eu tenho pesquisado como gerenciar as versões da API REST usando o Spring 3.2.x, mas não encontrei nada que seja fácil de manter. Vou explicar primeiro o problema que tenho, e depois uma solução … mas me pergunto se estou reinventando a roda aqui.

Desejo gerenciar a versão com base no header Accept e, por exemplo, se uma solicitação tiver o header Accept application/vnd.company.app-1.1+json , quero que o Spring MVC encaminhe isso para o método que manipula essa versão. E como nem todos os methods em uma API são alterados na mesma versão, não quero ir a cada um dos meus controladores e alterar qualquer coisa para um manipulador que não mudou entre as versões. Eu também não quero ter a lógica para descobrir qual versão usar no próprio controlador (usando localizadores de serviço) já que o Spring já está descobrindo qual método chamar.

Então peguei uma API com as versões 1.0, 1.8 onde um manipulador foi introduzido na versão 1.0 e modificado na v1.7, eu gostaria de lidar com isso da seguinte maneira. Imagine que o código esteja dentro de um controlador e que exista algum código capaz de extrair a versão do header. (O seguinte é inválido na spring)

 @RequestMapping(...) @VersionRange(1.0,1.6) @ResponseBody public Object method1() { // so something return object; } @RequestMapping(...) //same Request mapping annotation @VersionRange(1.7) @ResponseBody public Object method2() { // so something return object; } 

Isso não é possível na primavera, pois os dois methods têm a mesma anotação RequestMapping e o Spring falha ao carregar. A ideia é que a anotação VersionRange possa definir um intervalo de versão aberto ou fechado. O primeiro método é válido das versões 1.0 a 1.6, enquanto o segundo da versão 1.7 em diante (incluindo a última versão 1.8). Eu sei que esta abordagem se quebra se alguém decide passar a versão 99.99, mas isso é algo que eu estou bem para viver.

Agora, como o acima não é possível sem um retrabalho sério de como funciona a spring, eu estava pensando em mexer na maneira como os manipuladores correspondiam aos pedidos, em particular para escrever minha própria ProducesRequestCondition e ter o intervalo de versões lá. Por exemplo

Código:

 @RequestMapping(..., produces = "application/vnd.company.app-[1.0-1.6]+json) @ResponseBody public Object method1() { // so something return object; } @RequestMapping(..., produces = "application/vnd.company.app-[1.7-]+json) @ResponseBody public Object method2() { // so something return object; } 

Dessa forma, posso ter intervalos de versão fechados ou abertos definidos na parte de produção da anotação. Eu estou trabalhando nesta solução agora, com o problema que eu ainda tive que replace algumas classs principais Spring MVC ( RequestMappingInfoHandlerMapping , RequestMappingHandlerMapping e RequestMappingInfo ), que eu não gosto, porque significa trabalho extra sempre que eu decido atualizar para um versão mais nova da primavera.

Eu apreciaria quaisquer pensamentos … e especialmente, qualquer sugestão para fazer isso de uma maneira mais simples e fácil de manter.


Editar

Adicionando uma recompensa. Para obter a recompensa, por favor, responda a pergunta acima sem sugerir ter essa lógica no próprio controlador. Spring já tem muita lógica para selecionar qual método de controle chamar, e eu quero pegar carona nisso.


Editar 2

Eu compartilhei o POC original (com algumas melhorias) no github: https://github.com/augusto/restVersioning

Independentemente se o version control pode ser evitado fazendo alterações compatíveis com versões anteriores (o que pode nem sempre ser possível quando você está vinculado a algumas diretrizes corporativas ou seus clientes de API são implementados de maneira incorreta e quebrariam mesmo se não fossem) o requisito abstraído é interessante 1:

Como posso fazer um mapeamento de solicitação personalizado que faz avaliações arbitrárias de valores de header da solicitação sem fazer a avaliação no corpo do método?

Como descrito nesta resposta de SO, você pode ter o mesmo @RequestMapping e usar uma anotação diferente para diferenciar durante o roteamento real que acontece durante o tempo de execução. Para fazer isso, você terá que:

  1. Crie uma nova anotação VersionRange .
  2. Implemente um RequestCondition . Como você terá algo como um algoritmo de melhor correspondência, terá que verificar se os methods anotados com outros valores de VersionRange fornecem uma correspondência melhor para a solicitação atual.
  3. Implemente um VersionRangeRequestMappingHandlerMapping base na condição de anotação e solicitação (conforme descrito no post Como implementar as propriedades personalizadas @RequestMapping ).
  4. Configure a mola para avaliar seu VersionRangeRequestMappingHandlerMapping antes de usar o padrão RequestMappingHandlerMapping (por exemplo, definindo sua ordem como 0).

Isso não exigiria nenhuma substituição hacky dos componentes Spring, mas usa os mecanismos de configuração e extensão Spring, portanto, deve funcionar mesmo se você atualizar sua versão Spring (desde que a nova versão suporte esses mecanismos).

Acabei de criar uma solução personalizada. Estou usando a anotação @ApiVersion em combinação com a anotação @Controller dentro de classs @Controller .

Exemplo:

 @Controller @RequestMapping("x") @ApiVersion(1) class MyController { @RequestMapping("a") void a() {} // maps to /v1/x/a @RequestMapping("b") @ApiVersion(2) void b() {} // maps to /v2/x/b @RequestMapping("c") @ApiVersion({1,3}) void c() {} // maps to /v1/x/c // and to /v3/x/c } 

Implementação:

Anotação ApiVersion.java :

 @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface ApiVersion { int[] value(); } 

ApiVersionRequestMappingHandlerMapping.java (isto é principalmente copiar e colar de RequestMappingHandlerMapping ):

 public class ApiVersionRequestMappingHandlerMapping extends RequestMappingHandlerMapping { private final String prefix; public ApiVersionRequestMappingHandlerMapping(String prefix) { this.prefix = prefix; } @Override protected RequestMappingInfo getMappingForMethod(Method method, Class< ?> handlerType) { RequestMappingInfo info = super.getMappingForMethod(method, handlerType); if(info == null) return null; ApiVersion methodAnnotation = AnnotationUtils.findAnnotation(method, ApiVersion.class); if(methodAnnotation != null) { RequestCondition< ?> methodCondition = getCustomMethodCondition(method); // Concatenate our ApiVersion with the usual request mapping info = createApiVersionInfo(methodAnnotation, methodCondition).combine(info); } else { ApiVersion typeAnnotation = AnnotationUtils.findAnnotation(handlerType, ApiVersion.class); if(typeAnnotation != null) { RequestCondition< ?> typeCondition = getCustomTypeCondition(handlerType); // Concatenate our ApiVersion with the usual request mapping info = createApiVersionInfo(typeAnnotation, typeCondition).combine(info); } } return info; } private RequestMappingInfo createApiVersionInfo(ApiVersion annotation, RequestCondition< ?> customCondition) { int[] values = annotation.value(); String[] patterns = new String[values.length]; for(int i=0; i 

Injeção no WebMvcConfigurationSupport:

 public class WebMvcConfig extends WebMvcConfigurationSupport { @Override public RequestMappingHandlerMapping requestMappingHandlerMapping() { return new ApiVersionRequestMappingHandlerMapping("v"); } } 

Eu ainda recomendaria o uso de URLs para version control, porque em URLs @RequestMapping suporta padrões e parâmetros de caminho, cujo formato poderia ser especificado com regexp.

E para lidar com upgrades de clientes (que você mencionou no comentário), você pode usar aliases como ‘latest’. Ou tem versão não versionada da API que usa a versão mais recente (sim).

Também usando parâmetros de caminho você pode implementar qualquer lógica de manipulação de versão complexa, e se você já quer ter intervalos, você pode querer algo mais rápido o bastante.

Aqui está um par de exemplos:

 @RequestMapping({ "/**/public_api/1.1/method", "/**/public_api/1.2/method", }) public void method1(){ } @RequestMapping({ "/**/public_api/1.3/method" "/**/public_api/latest/method" "/**/public_api/method" }) public void method2(){ } @RequestMapping({ "/**/public_api/1.4/method" "/**/public_api/beta/method" }) public void method2(){ } //handles all 1.* requests @RequestMapping({ "/**/public_api/{version:1\\.\\d+}/method" }) public void methodManual1(@PathVariable("version") String version){ } //handles 1.0-1.6 range, but somewhat ugly @RequestMapping({ "/**/public_api/{version:1\\.[0123456]?}/method" }) public void methodManual1(@PathVariable("version") String version){ } //fully manual version handling @RequestMapping({ "/**/public_api/{version}/method" }) public void methodManual2(@PathVariable("version") String version){ int[] versionParts = getVersionParts(version); //manual handling of versions } public int[] getVersionParts(String version){ try{ String[] versionParts = version.split("\\."); int[] result = new int[versionParts.length]; for(int i=0;i 

Com base na última abordagem, você pode realmente implementar algo parecido com o que deseja.

Por exemplo, você pode ter um controlador que contenha apenas tentativas de método com manipulação de versão.

Nessa manipulação você procura (usando bibliotecas de geração de código / reflection / AOP) em algum serviço / componente de mola ou na mesma class para o método com o mesmo nome / assinatura e @VersionRange necessário e invoca-o passando todos os parâmetros.

Eu implementei uma solução que lida PERFEITAMENTE com o problema de versões em descanso.

General Speaking existem 3 abordagens principais para versões em descanso:

  • Aproximação baseada em caminho , em que o cliente define a versão no URL:

     http://localhost:9001/api/v1/user http://localhost:9001/api/v2/user 
  • Cabeçalho Content-Type , no qual o cliente define a versão no header Accept :

     http://localhost:9001/api/v1/user with Accept: application/vnd.app-1.0+json OR application/vnd.app-2.0+json 
  • Cabeçalho personalizado , em que o cliente define a versão em um header personalizado.

O problema com a primeira abordagem é que, se você alterar a versão, digamos de v1 -> v2, provavelmente será necessário copiar e colar os resources v1 que não foram alterados para o caminho v2

O problema com a segunda abordagem é que algumas ferramentas como http://swagger.io/ não podem ser distintas entre operações com o mesmo caminho, mas diferentes Content-Type (verifique o problema https://github.com/OAI/OpenAPI-Specification/issues/ 146 )

A solução

Desde que eu estou trabalhando muito com ferramentas de documentação de descanso, eu prefiro usar a primeira abordagem. Minha solução lida com o problema com a primeira abordagem, para que você não precise copiar e colar o ponto de extremidade para a nova versão.

Vamos supor que temos versões v1 e v2 para o controlador User:

 package com.mspapant.example.restVersion.controller; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseBody; /** * The user controller. * * @author : Manos Papantonakos on 19/8/2016. */ @Controller @Api(value = "user", description = "Operations about users") public class UserController { /** * Return the user. * * @return the user */ @ResponseBody @RequestMapping(method = RequestMethod.GET, value = "/api/v1/user") @ApiOperation(value = "Returns user", notes = "Returns the user", tags = {"GET", "User"}) public String getUserV1() { return "User V1"; } /** * Return the user. * * @return the user */ @ResponseBody @RequestMapping(method = RequestMethod.GET, value = "/api/v2/user") @ApiOperation(value = "Returns user", notes = "Returns the user", tags = {"GET", "User"}) public String getUserV2() { return "User V2"; } } 

O requisito é que, se eu solicitar a v1 para o recurso do usuário, eu tenho que fazer a repsonse do “User V1” , caso contrário, se eu solicitar a v2 , v3 e assim por diante, eu tenho que tomar a resposta “User V2” .

insira a descrição da imagem aqui

Para implementar isso na primavera, precisamos sobrescrever o comportamento padrão RequestMappingHandlerMapping :

 package com.mspapant.example.restVersion.conf.mapping; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; public class VersionRequestMappingHandlerMapping extends RequestMappingHandlerMapping { @Value("${server.apiContext}") private String apiContext; @Value("${server.versionContext}") private String versionContext; @Override protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception { HandlerMethod method = super.lookupHandlerMethod(lookupPath, request); if (method == null && lookupPath.contains(getApiAndVersionContext())) { String afterAPIURL = lookupPath.substring(lookupPath.indexOf(getApiAndVersionContext()) + getApiAndVersionContext().length()); String version = afterAPIURL.substring(0, afterAPIURL.indexOf("/")); String path = afterAPIURL.substring(version.length() + 1); int previousVersion = getPreviousVersion(version); if (previousVersion != 0) { lookupPath = getApiAndVersionContext() + previousVersion + "/" + path; final String lookupFinal = lookupPath; return lookupHandlerMethod(lookupPath, new HttpServletRequestWrapper(request) { @Override public String getRequestURI() { return lookupFinal; } @Override public String getServletPath() { return lookupFinal; }}); } } return method; } private String getApiAndVersionContext() { return "/" + apiContext + "/" + versionContext; } private int getPreviousVersion(final String version) { return new Integer(version) - 1 ; } 

}

A implementação lê a versão na URL e pede à primavera para resolver a URL. Caso essa URL não exista (por exemplo, o cliente solicitou a v3 ), tentamos com a v2 e assim por diante até encontrarmos a versão mais recente para o recurso. .

Para ver os benefícios dessa implementação, digamos que temos dois resources: Usuário e empresa:

 http://localhost:9001/api/v{version}/user http://localhost:9001/api/v{version}/company 

Digamos que fizemos uma mudança no “contrato” da empresa que quebra o cliente. Portanto, implementamos o http://localhost:9001/api/v2/company e pedimos ao cliente que mude para v2 em vez de v1.

Portanto, as novas solicitações do cliente são:

 http://localhost:9001/api/v2/user http://localhost:9001/api/v2/company 

ao invés de:

 http://localhost:9001/api/v1/user http://localhost:9001/api/v1/company 

A melhor parte aqui é que, com essa solução, o cliente obterá as informações do usuário v1 e as informações da empresa da v2 sem a necessidade de criar um novo (mesmo) terminal do usuário v2!

Documentação Restante Como eu disse antes, a razão pela qual eu seleciono a abordagem de versionamento baseada em URL é que algumas ferramentas como swagger não documentam diferentemente os terminais com a mesma URL, mas com diferentes tipos de conteúdo. Com essa solução, os dois terminais são exibidos, pois possuem um URL diferente:

insira a descrição da imagem aqui

GIT

Implementação da solução em: https://github.com/mspapant/restVersioningExample/

A anotação @RequestMapping suporta um elemento de headers que permite restringir as solicitações correspondentes. Em particular, você pode usar o header Accept aqui.

 @RequestMapping(headers = { "Accept=application/vnd.company.app-1.0+json", "Accept=application/vnd.company.app-1.1+json" }) 

Isso não é exatamente o que você está descrevendo, já que ele não lida diretamente com intervalos, mas o elemento suporta o curinga *, bem como! =. Assim, pelo menos, você poderia usar um curinga para os casos em que todas as versões suportam o terminal em questão, ou até mesmo todas as versões secundárias de uma determinada versão principal (por exemplo, 1. *).

Eu não acho que eu usei esse elemento antes (se eu não me lembro), então eu estou saindo da documentação

http://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/web/bind/annotation/RequestMapping.html

Que tal apenas usando inheritance para modelar versionamento? Isso é o que estou usando no meu projeto e não requer nenhuma configuração especial de mola e me dá exatamente o que eu quero.

 @RestController @RequestMapping(value = "/test/1") @Deprecated public class Test1 { ...Fields Getters Setters... @RequestMapping(method = RequestMethod.GET) @Deprecated public Test getTest(Long id) { return serviceClass.getTestById(id); } @RequestMapping(method = RequestMethod.PUT) public Test getTest(Test test) { return serviceClass.updateTest(test); } } @RestController @RequestMapping(value = "/test/2") public class Test2 extends Test1 { ...Fields Getters Setters... @Override @RequestMapping(method = RequestMethod.GET) public Test getTest(Long id) { return serviceClass.getAUpdated(id); } @RequestMapping(method = RequestMethod.DELETE) public Test deleteTest(Long id) { return serviceClass.deleteTestById(id); } } 

Essa configuração permite pouca duplicação de código e a capacidade de replace methods em novas versões da API com pouco trabalho. Também evita a necessidade de complicar o código-fonte com a lógica de troca de versão. Se você não codificar um ponto de extremidade em uma versão, ele pegará a versão anterior por padrão.

Comparado com o que os outros estão fazendo isso parece mais fácil. Tem algo que estou perdendo?

Nos produz, você pode ter negação. Portanto, para o método 1, digamos, produces="!...1.7" e no método2, o positivo.

O produtor também é um array, então você pode usar o method1 para produces={"...1.6","!...1.7","...1.8"} etc (aceitar todos, exceto 1.7)

Claro que não é tão ideal quanto os intervalos que você tem em mente, mas eu acho mais fácil de manter do que outras coisas personalizadas, se isso é algo incomum em seu sistema. Boa sorte!

Você pode usar AOP, em torno de interceptação

Considere ter um mapeamento de solicitação que receba todo o /**/public_api/* e, nesse método, não faça nada;

 @RequestMapping({ "/**/public_api/*" }) public void method2(Model model){ } 

Depois de

 @Override public void around(Method method, Object[] args, Object target) throws Throwable { // look for the requested version from model parameter, call it desired range // check the target object for @VersionRange annotation with reflection and acquire version ranges, call the function if it is in the desired range } 

A única restrição é que tudo tem que estar no mesmo controlador.

Para a configuração do AOP, consulte http://www.mkyong.com/spring/spring-aop-examples-advice/