¿Cómo gestionar el control de versiones de la API REST con Spring?

He estado buscando cómo administrar las versiones de la API REST con Spring 3.2.x, pero no he encontrado nada que sea fácil de mantener. Explicaré primero el problema que tengo, y luego una solución … pero me pregunto si estoy reinventando la rueda aquí.

Quiero administrar la versión basada en el encabezado Aceptar y, por ejemplo, si una solicitud tiene la application/vnd.company.app-1.1+json encabezado Aceptar application/vnd.company.app-1.1+json , quiero que Spring MVC lo reenvíe al método que maneja esta versión. Y dado que no todos los métodos en una API cambian en la misma versión, no quiero ir a cada uno de mis controladores y cambiar cualquier cosa por un controlador que no haya cambiado entre versiones. Tampoco quiero tener la lógica para averiguar qué versión usar en el controlador (utilizando localizadores de servicio) ya que Spring ya está descubriendo qué método llamar.

Así que tomé una API con las versiones 1.0, a la 1.8, donde se introdujo un controlador en la versión 1.0 y se modificó en v1.7. Me gustaría manejar esto de la siguiente manera. Imagine que el código está dentro de un controlador y que hay algún código que puede extraer la versión del encabezado. (Lo siguiente no es válido en 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; } 

Esto no es posible en spring ya que los 2 métodos tienen la misma anotación RequestMapping y Spring no se carga. La idea es que la anotación VersionRange puede definir un rango de versión abierta o cerrada. El primer método es válido de las versiones 1.0 a 1.6, mientras que el segundo para la versión 1.7 en adelante (incluida la última versión 1.8). Sé que este enfoque se rompe si alguien decide aprobar la versión 99.99, pero eso es algo con lo que estoy de acuerdo.

Ahora, dado que lo anterior no es posible sin una revisión seria de cómo funciona la spring, estaba pensando en retocar la forma en que los manejadores coinciden con las solicitudes, en particular para escribir mi propia ProducesRequestCondition , y tener el rango de versiones allí. Por ejemplo

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; } 

De esta forma, puedo haber cerrado o abierto rangos de versión definidos en la parte de producción de la anotación. Estoy trabajando en esta solución ahora, con el problema de que aún tuve que reemplazar algunas clases principales de Spring MVC ( RequestMappingInfoHandlerMapping , RequestMappingHandlerMapping y RequestMappingInfo ), lo que no me gusta, porque significa un trabajo adicional cada vez que decido actualizar a un versión más nueva de la spring.

Agradecería cualquier idea … y especialmente, cualquier sugerencia para hacer esto de una manera más simple y fácil de mantener.


Editar

Agregar una recompensa. Para obtener la recompensa, responda la pregunta anterior sin sugerir tener esta lógica en el controlador. Spring ya tiene mucha lógica para seleccionar qué método de controlador llamar, y quiero aprovecharlo.


Editar 2

Compartí el POC original (con algunas mejoras) en github: https://github.com/augusto/restVersioning

Independientemente de si se puede evitar el control de versiones haciendo cambios compatibles hacia atrás (lo que no siempre es posible cuando se está obligado por algunas directrices corporativas o los clientes de la API se implementan de manera defectuosa y se rompen incluso si no) el requisito abstracto es un interesante uno:

¿Cómo puedo hacer una asignación de solicitud personalizada que realice evaluaciones arbitrarias de los valores de encabezado de la solicitud sin realizar la evaluación en el cuerpo del método?

Como se describe en esta respuesta SO , en realidad puede tener el mismo @RequestMapping y usar una anotación diferente para diferenciar durante el enrutamiento real que ocurre durante el tiempo de ejecución. Para hacerlo, deberá:

  1. Crea una nueva anotación VersionRange .
  2. Implementar una RequestCondition . Como tendrá algo así como un algoritmo de mejor coincidencia, deberá comprobar si los métodos anotados con otros valores de VersionRange proporcionan una mejor coincidencia para la solicitud actual.
  3. Implemente un VersionRangeRequestMappingHandlerMapping basado en la condición de anotación y solicitud (como se describe en la publicación Cómo implementar las propiedades personalizadas @RequestMapping ).
  4. Configure el muelle para evaluar su VersionRangeRequestMappingHandlerMapping antes de usar el RequestMappingHandlerMapping (por ejemplo, estableciendo su orden en 0).

Esto no requerirá ningún reemplazo hacky de los componentes de Spring, pero utiliza la configuración de Spring y los mecanismos de extensión, por lo que debería funcionar incluso si actualiza su versión de Spring (siempre que la nueva versión sea compatible con estos mecanismos).

Acabo de crear una solución personalizada. Estoy usando la anotación @ApiVersion en combinación con la anotación @RequestMapping dentro de @Controller clases @Controller .

Ejemplo:

 @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 } 

Implementación:

ApiVersion.java anotación:

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

ApiVersionRequestMappingHandlerMapping.java (esto es principalmente copiar y pegar desde 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 

Inyección en WebMvcConfigurationSupport:

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

Aún así, recomendaría usar URL para el control de versiones porque en las URL @RequestMapping admite patrones y parámetros de ruta, que formato podría especificarse con regexp.

Y para manejar las actualizaciones de los clientes (que mencionaste en los comentarios) puedes usar alias como ‘latest’. O tiene una versión no versionada de api que usa la última versión (sí).

También puede usar parámetros de ruta para implementar cualquier lógica de manejo de versiones complejas, y si ya quiere tener rangos, muy bien podría querer algo más pronto.

Aquí hay un par de ejemplos:

 @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 

Según el último enfoque, puedes implementar algo como lo que quieres.

Por ejemplo, puede tener un controlador que contenga solo ataques de método con manejo de versiones.

En ese manejo usted mira (usando las bibliotecas de reflexión / generación de código / AOP) en algún servicio / componente de spring o en la misma clase para el método con el mismo nombre / firma y requiere @VersionRange e invocarlo pasando todos los parámetros.

Implementé una solución que maneja PERFECTAMENTE el problema con el control de versiones en reposo.

Hablando en general, hay 3 enfoques principales para el control de versiones en reposo:

  • Aprobación basada en ruta, en la que el cliente define la versión en la URL:

     http://localhost:9001/api/v1/user http://localhost:9001/api/v2/user 
  • Encabezado de tipo de contenido , en el que el cliente define la versión en el encabezado Aceptar :

     http://localhost:9001/api/v1/user with Accept: application/vnd.app-1.0+json OR application/vnd.app-2.0+json 
  • Encabezado personalizado , en el que el cliente define la versión en un encabezado personalizado.

El problema con el primer enfoque es que si cambia la versión digamos desde v1 -> v2, probablemente necesite copiar y pegar los recursos v1 que no han cambiado a la ruta v2

El problema con el segundo enfoque es que algunas herramientas como http://swagger.io/ no pueden distinguir entre las operaciones con la misma ruta pero diferente Content-Type (ver problema https://github.com/OAI/OpenAPI-Specification/issues/ 146 )

La solución

Como estoy trabajando mucho con las herramientas de documentación de descanso, prefiero usar el primer enfoque. Mi solución maneja el problema con el primer enfoque, por lo que no es necesario copiar y pegar el punto final a la nueva versión.

Digamos que tenemos versiones v1 y v2 para el controlador de usuario:

 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"; } } 

El requisito es que si solicito v1 para el recurso de usuario, tengo que tomar la respuesta “Usuario V1” ; de lo contrario, si solicito v2 , v3 y así sucesivamente, debo tomar la respuesta de “Usuario V2” .

enter image description here

Para implementar esto en la spring, debemos sobrescribir el comportamiento predeterminado de 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 ; } 

}

La implementación lee la versión en la URL y pide desde la spring que resuelva la URL. En caso de que esta URL no exista (por ejemplo, el cliente solicitó v3 ), intentamos con v2 y así hasta encontrar la versión más reciente para el recurso .

Para ver los beneficios de esta implementación, digamos que tenemos dos recursos: usuario y empresa:

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

Digamos que hicimos un cambio en el “contrato” de la empresa que rompe al cliente. Entonces implementamos la compañía http://localhost:9001/api/v2/company y le pedimos al cliente que cambie a v2 en lugar de v1.

Entonces, las nuevas solicitudes del cliente son:

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

en lugar de:

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

La mejor parte es que con esta solución el cliente obtendrá la información del usuario de v1 y la información de la empresa de v2 sin la necesidad de crear un nuevo (mismo) punto final del usuario v2.

Rest Documentation Como dije antes, el motivo por el que selecciono el enfoque de control de versiones basado en URL es que algunas herramientas como swagger no documentan de manera diferente los puntos finales con la misma URL pero diferente tipo de contenido. Con esta solución, ambos puntos finales se muestran ya que tienen URL diferentes:

enter image description here

GIT

Implementación de la solución en: https://github.com/mspapant/restVersioningExample/

La anotación @RequestMapping admite un elemento de headers que le permite restringir las solicitudes de coincidencia. En particular, puede usar el encabezado Accept aquí.

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

Esto no es exactamente lo que está describiendo, ya que no maneja directamente los rangos, pero el elemento admite el * comodín, así como! =. Por lo tanto, al menos podría salirse con la suya usando un comodín para los casos en que todas las versiones sean compatibles con el punto final en cuestión, o incluso todas las versiones menores de una versión principal determinada (por ejemplo, 1. *).

No creo que haya usado este elemento antes (si es que no lo recuerdo), así que estoy saliendo de la documentación en

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

¿Qué tal si usamos la herencia para modelar las versiones? Eso es lo que estoy usando en mi proyecto y no requiere una configuración de resorte especial y me da exactamente lo que quiero.

 @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); } } 

Esta configuración permite poca duplicación de código y la capacidad de sobrescribir métodos en nuevas versiones de la API con poco trabajo. También ahorra la necesidad de complicar su código fuente con la lógica de cambio de versión. Si no codifica un punto final en una versión, obtendrá la versión anterior de forma predeterminada.

Comparado con lo que otros están haciendo, esto parece mucho más fácil. ¿Se me escapa algo?

En produce puedes tener negación. Entonces, para el método 1, digamos produces="!...1.7" y en el método2 tiene el positivo.

El produce también es una matriz así que para method1 puedes decir produces={"...1.6","!...1.7","...1.8"} etc (acepta todos excepto 1.7)

Por supuesto, no es tan ideal como los rangos que tiene en mente, pero creo que es más fácil de mantener que otras cosas personalizadas si esto es algo poco común en su sistema. ¡Buena suerte!

Puedes usar AOP, alrededor de la interceptación

Considere tener un mapeo de solicitud que reciba todo el /**/public_api/* y en este método no haga nada;

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

Después

 @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 } 

La única restricción es que todo tiene que estar en el mismo controlador.

Para la configuración de AOP, consulte http://www.mkyong.com/spring/spring-aop-examples-advice/