Comment gérer la gestion des versions de l'API REST avec spring?


J'ai cherché comment gérer les versions d'une API REST à l'aide de Spring 3.2.x, mais je n'ai rien trouvé de facile à entretenir. Je vais d'abord expliquer le problème que j'ai, puis une solution... mais je me demande si je réinvente la roue ici.

Je veux gérer la version en fonction de l'en-tête Accept, et par exemple si une demande a l'en-tête Accept application/vnd.company.app-1.1+json, je veux que spring MVC transmette cela à la méthode qui gère cette version. Et puisque toutes les méthodes d'une API ne changent pas la même version, je ne veux pas aller à chacun de mes contrôleurs et changer quoi que ce soit pour un gestionnaire qui n'a pas changé entre les versions. Je ne veux pas non plus avoir la logique pour savoir quelle version utiliser dans le contrôleur lui-même (en utilisant des localisateurs de service) car Spring découvre déjà quelle méthode appeler.

Donc pris une API avec les versions 1.0, à 1.8 où un gestionnaire a été introduit dans la version 1.0 et modifié dans la v1.7, je voudrais gérer cela de la manière suivante. Imaginez que le code est à l'intérieur d'un contrôleur, et qu'il y a du code qui est capable d'extraire la version de l'en-tête. (Ce qui suit est invalide au printemps)

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

Ce n'est pas possible dans spring car les 2 méthodes ont la même annotation RequestMapping et Spring ne se charge pas. L'idée est que l'annotation VersionRange peut définir une plage de versions ouverte ou fermée. La première méthode est valide à partir des versions 1.0 à 1.6, tandis que la seconde pour la version 1.7 (y compris la dernière version 1.8). Je le sais cette approche se brise si quelqu'un décide de passer la version 99.99, mais c'est quelque chose avec lequel je peux vivre.

Maintenant, puisque ce qui précède n'est pas possible sans une refonte sérieuse du fonctionnement de spring, je pensais à bricoler la façon dont les gestionnaires correspondaient aux demandes, en particulier pour écrire mon propre ProducesRequestCondition, et avoir la plage de versions là-dedans. Par exemple

Code:

@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 cette façon, je peux avoir des plages de versions fermées ou ouvertes définies dans la partie produit de l'annotation. Je travaille sur cette solution maintenant, avec le problème que je devais encore remplacer certaines classes Core Spring MVC(RequestMappingInfoHandlerMapping, RequestMappingHandlerMapping et RequestMappingInfo), ce que je n'aime pas, car cela signifie du travail supplémentaire chaque fois que je décide de passer à une version plus récente de spring.

J'apprécierais toutes les pensées... et surtout, toute suggestion de le faire d'une manière plus simple, plus facile à maintenir.


Modifier

Ajout d'une prime. Pour obtenir la prime, veuillez répondre à la question ci-dessus sans suggérer à ont cette logique dans le contrôleur eux-mêmes. Spring a déjà beaucoup de logique pour sélectionner la méthode de contrôleur à appeler, et je veux y revenir.


Modifier 2

J'ai partagé le POC original (avec quelques améliorations) dans github: https://github.com/augusto/restVersioning

Author: Augusto, 2013-11-25

8 answers

Peu importe si le versionnage peut être évité en effectuant des modifications rétrocompatibles (ce qui n'est pas toujours possible lorsque vous êtes lié par certaines directives d'entreprise ou que vos clients API sont implémentés de manière boguée et casseraient même s'ils ne le devraient pas), l'exigence abstraite est intéressante:

Comment puis-je faire un mappage de demande personnalisé qui effectue des évaluations arbitraires des valeurs d'en-tête de la demande sans faire l'évaluation dans la méthode corps?

Comme décrit dans cette réponse SO vous pouvez réellement avoir le même @RequestMapping et utiliser une annotation différente pour différencier pendant le routage réel qui se produit pendant l'exécution. Pour ce faire, vous devrez:

  1. Créer une nouvelle annotation VersionRange.
  2. Implémenter un RequestCondition<VersionRange>. Puisque vous aurez quelque chose comme un algorithme de meilleure correspondance, vous devrez vérifier si les méthodes annotées avec d'autres valeurs VersionRange fournissent une meilleure correspondance pour le courant demande.
  3. Implémenter un VersionRangeRequestMappingHandlerMapping basé sur la condition d'annotation et de demande (comme décrit dans le post Comment implémenter les propriétés personnalisées @RequestMapping ).
  4. Configurez spring pour évaluer votre VersionRangeRequestMappingHandlerMapping avant d'utiliser le RequestMappingHandlerMapping par défaut (par exemple en définissant son ordre sur 0).

Cela ne nécessiterait aucun remplacement hacky des composants Spring mais utilise la configuration Spring et les mécanismes d'extension, donc cela devrait fonctionner même si vous mettez à jour votre version Spring (comme tant que la nouvelle version prend en charge ces mécanismes).

 50
Author: xwoker, 2018-02-22 05:59:15

Je viens de créer une solution personnalisée. J'utilise l'annotation @ApiVersion en combinaison avec l'annotation @RequestMapping dans les classes @Controller.

Exemple:

@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

}

Mise en œuvre:

ApiVersion.java annotation:

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

ApiVersionRequestMappingHandlerMapping.java (il s'agit principalement de copier-coller à partir 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<values.length; i++) {
            // Build the URL prefix
            patterns[i] = prefix+values[i]; 
        }

        return new RequestMappingInfo(
                new PatternsRequestCondition(patterns, getUrlPathHelper(), getPathMatcher(), useSuffixPatternMatch(), useTrailingSlashMatch(), getFileExtensions()),
                new RequestMethodsRequestCondition(),
                new ParamsRequestCondition(),
                new HeadersRequestCondition(),
                new ConsumesRequestCondition(),
                new ProducesRequestCondition(),
                customCondition);
    }

}

Injection dans WebMvcConfigurationSupport:

public class WebMvcConfig extends WebMvcConfigurationSupport {
    @Override
    public RequestMappingHandlerMapping requestMappingHandlerMapping() {
        return new ApiVersionRequestMappingHandlerMapping("v");
    }
}
 37
Author: Benjamin M, 2017-02-09 16:41:52

Je recommanderais toujours d'utiliser des URL pour le versioning car dans URLs @RequestMapping prend en charge les modèles et les paramètres de chemin, dont le format peut être spécifié avec regexp.

Et pour gérer les mises à niveau des clients (que vous avez mentionnées dans le commentaire), vous pouvez utiliser des alias comme 'latest'. Ou avoir une version non versionnée de l'API qui utilise la dernière version (ouais).

En utilisant également les paramètres de chemin, vous pouvez implémenter n'importe quelle logique de gestion de version complexe, et si vous voulez déjà avoir des plages, vous pouvez très bien vous voudrez peut-être quelque chose de plus assez tôt.

Voici quelques exemples:

@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<versionParts.length;i++){
            result[i] = Integer.parseInt(versionParts[i]);
        }
        return result;
    }catch (Exception ex) {
        return null;
    }
}

Sur la base de la dernière approche, vous pouvez réellement implémenter quelque chose comme ce que vous voulez.

Par exemple, vous pouvez avoir un contrôleur qui ne contient que des stabs de méthode avec la gestion de version.

Dans cette gestion, vous regardez (en utilisant les bibliothèques de génération de réflexion/AOP/code) dans un service/composant spring ou dans la même classe pour la méthode avec le même nom / signature et requis @ VersionRange et l'invoquer en passant tous les paramètres.

 16
Author: elusive-code, 2013-12-01 07:58:15

J'ai implémenté une solution qui gère PARFAITEMENT le problème avec le versionnage rest.

En général, il existe 3 approches principales pour le versioning rest:

  • Approche basée sur le chemin , dans laquelle le client définit la version dans l'URL:

    http://localhost:9001/api/v1/user
    http://localhost:9001/api/v2/user
    
  • Content-Type - tête, dans lequel le client définit la version de Accepter - tête:

    http://localhost:9001/api/v1/user with 
    Accept: application/vnd.app-1.0+json OR application/vnd.app-2.0+json
    
  • L'en-Tête Personnalisé, dans lequel l' client définit la version dans un en-tête personnalisé.

Le problème avec l'approche first est que si vous changez la version disons de v1 - > v2, vous devez probablement copier-coller les ressources v1 qui n'ont pas changé en chemin v2

Le problème avec le deuxième approche est que certains outils comme http://swagger.io / impossible de distinguer entre les opérations avec le même chemin mais un type de contenu différent (problème de vérification https://github.com/OAI/OpenAPI-Specification/issues/146 )

La solution

Comme je travaille beaucoup avec les outils de documentation rest, je préfère utiliser la première approche. Ma solution gère le problème avec la première approche, vous n'avez donc pas besoin de copier-coller le point de terminaison dans la nouvelle version.

Disons que nous avons des versions v1 et v2 pour le contrôleur utilisateur:

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

L'exigence est si je demande le v1 pour la ressource de l'utilisateur-je prendre le "Utilisateur V1" repsonse, sinon, si je demande à la v2, v3 et ainsi de suite je dois prendre le "Utilisateur V2" réponse.

entrez la description de l'image ici

Pour implémenter cela au printemps, nous devons remplacer le comportement par défaut 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 ;
    }

}

L'implémentation lit la version dans l'URL et demande à spring de résoudre l'URL .Au cas où cette URL ne le ferait pas existe (par exemple le client a demandé v3) puis nous essayons avec v2 et donc un jusqu'à ce que nous trouvions la version la plus récente pour la ressource.

Pour voir les avantages de cette implémentation, disons que nous avons deux ressources: User et Company:

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

Disons que nous avons fait un changement dans le "contrat" de l'entreprise qui brise le client. Nous implémentons donc le http://localhost:9001/api/v2/company et nous demandons au client de passer à v2 à la place sur v1.

Donc les nouvelles demandes du client sont:

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

Au Lieu de:

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

La partie bestici est qu'avec cette solution, le client obtiendra les informations utilisateur de v1 et les informations sur l'entreprise de v2 sans avoir besoin de pour créer un nouveau (même) point de terminaison à partir de l'utilisateur v2!

Reste De La Documentation Comme je l'ai dit précédemment, la raison pour laquelle je sélectionne l'approche de gestion des versions basée sur les URL est que certains outils comme swagger ne documentent pas différemment les points de terminaison avec la même URL mais un contenu différent type. Avec cette solution, les deux points de terminaison sont affichés car ils ont une URL différente:

entrez la description de l'image ici

GIT

Mise en œuvre de la solution à: https://github.com/mspapant/restVersioningExample/

 8
Author: mspapant, 2016-08-21 16:00:43

L'annotation @RequestMapping prend en charge un élément headers qui vous permet de réduire les requêtes correspondantes. En particulier, vous pouvez utiliser l'en-tête Accept ici.

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

Ce n'est pas exactement ce que vous décrivez, car il ne gère pas directement les plages, mais l'élément prend en charge le caractère générique * ainsi que !=. Donc, au moins, vous pouvez vous en sortir avec un caractère générique pour les cas où toutes les versions prennent en charge le point de terminaison en question, ou même toutes les versions mineures d'une version majeure donnée (par exemple 1.*).

Je ne pense pas avoir utilisé cet élément auparavant (si je ne m'en souviens pas), donc je vais juste sortir de la documentation à

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

 6
Author: Willie Wheeler, 2013-12-01 08:57:14

Qu'en est-il de l'utilisation de l'héritage pour modéliser la gestion des versions? C'est ce que j'utilise dans mon projet et il ne nécessite aucune configuration de ressort spéciale et m'obtient exactement ce que je veux.

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

Cette configuration permet peu de duplication de code et la possibilité d'écraser les méthodes dans de nouvelles versions de l'api avec peu de travail. Cela évite également de compliquer votre code source avec une logique de commutation de version. Si vous ne codez pas un point de terminaison dans une version il récupérera la version précédente en défaut.

Par rapport à ce que les autres font, cela semble beaucoup plus facile. Est-il quelque chose que je suis absent?

 2
Author: Ceekay, 2016-02-05 22:58:30

Dans les produits, vous pouvez avoir une négation. Donc, pour method1, disons produces="!...1.7" et dans method2 ont le positif.

Le produit est également un tableau, donc vous pouvez dire produces={"...1.6","!...1.7","...1.8"} etc. (accepter tout sauf 1.7)

Bien sûr pas aussi idéal que les plages que vous avez à l'esprit, mais je pense plus facile à maintenir que d'autres trucs personnalisés si c'est quelque chose d'inhabituel dans votre système. Bonne chance!

 1
Author: codesalsa, 2013-11-27 23:07:50

Vous pouvez utiliser AOP, autour de l'interception

Envisager d'avoir un mappage de requête qui reçoit tous les /**/public_api/* et dans cette méthode ne rien faire;

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

Aprè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 seule contrainte est que tous doivent être dans le même contrôleur.

Pour la configuration AOP, consultez http://www.mkyong.com/spring/spring-aop-examples-advice/

 0
Author: hevi, 2013-12-04 15:56:39