Как создать гибкий API с grails

Итак, немного фона. Я создаю сайт с довольно полным api. Api должен иметь возможность обрабатывать изменения, поэтому я выполнил версию api с api url эквивалентом чего-то вроде /api/0.2/$apiKey/$controller/$action/$id.

Я хочу, чтобы иметь возможность повторно использовать мои контроллеры для api, а также стандартное представление html. Сначала решение было использовано с блокомFormat во всех моих действиях (через частную функцию, совместно используемую в моих блоках действий).

Мне не нравится дубликат кода, и поэтому я хочу централизовать функционалFormat. поэтому вместо того, чтобы иметь кучу контроллеров и действий, имеющих свой собственный блокFormat, я бы хотел, чтобы это была либо услуга (однако у нас нет доступа к render() на сервисах, не так ли?) или у вас есть фильтр, который может отображать выходные данные в соответствии с согласованием содержимого графиков.

В моем текущем решении указан этот фильтр:

            after = { model ->
            def controller = grailsApplication.controllerClasses.find { controller ->
                controller.logicalPropertyName == controllerName
            }
            def action = applicationContext.getBean(controller.fullName).class.declaredFields.find{ field -> field.name == actionName }

            if(model && (isControllerApiRenderable(controller) || isActionApiRenderable(action))){
                switch(request.format){
                    case 'json':
                        render text:model as JSON, contentType: "application/json"
                        return false
                    case 'xml':
                        render text:model as XML, contentType: "application/xml"
                        return false
                    default:
                        render status: 406
                        return false
                }
            }
            return true
        }

В качестве примера, все, что я должен сделать в контроллере для рендеринга xml или json:

@ApiRenderable
def list = {
  def collectionOfSomething = SomeDomain.findAllBySomething('someCriteria')
  return [someCollection:collectionOfSomething]
}

теперь, если я обращаюсь к URL-адресу, который запускает этот список действий, (/api/0.2/apikey/controller/list.json или /api/ 0.2/apikey/controller/list?format=json или с заголовками: type: application/json), тогда ответ будет закодирован следующим образом:

{

      someCollection: [
          {
              someData: 'someData'
          },
          {
              someData: 'someData2'
          }  
      ]

}

Это очень хорошо, если я всегда хочу вернуть hashmap (который в настоящее время является требованием от контроллеров), но в этом примере все, что я хотел вернуть, было фактическим списком! а не список, заключенный в хэш файл....

Есть ли у кого-нибудь указатели на то, как создавать хорошие функциональные возможности api, которые являются надежными и гибкими и которые следуют принципу DRY, которые могут обрабатывать управление версиями (/api/0.1/, /api/0.2/) и могут обрабатывать различные сортировки в зависимости от контекста, в котором он возвращается? Любые советы приветствуются!

Ответ 1

Хорошо, вот что я сделал до сих пор, что, на мой взгляд, дает мне немного гибкости. Это, вероятно, много, чтобы читать, но любые предложения по улучшениям или изменениям приветствуются!

Пользовательский фильтр

class ApiFilters {

    def authenticateService

    def filters = {
        authenticateApiUsage(uri:"/api/**") {
            before = {
                if(authenticateService.isLoggedIn() || false){
                    //todo authenticate apiKey and apiSession
                    return true
                }else{
                    return false
                }
            }
            after = {
            }
            afterView = {
            }
        }
        renderProperContent(uri:"/api/**"){
            before = {
                //may be cpu heavy operation using reflection, initial tests show 100ms was used on first request, 10ms on subsequent.
                def controller = grailsApplication.controllerClasses.find { controller ->
                    controller.logicalPropertyName == controllerName
                }
                def action = applicationContext.getBean(controller.fullName).class.declaredFields.find{ field -> field.name == actionName }

                if(isControllerApiRenderable(controller) || isActionApiRenderable(action)){
                    if(isActionApiCorrectVersion(action,params.version)){
                        return true
                    }else{
                        render status: 415, text: "unsupported version"
                        return false
                    }
                }
            }
            after = { model ->
               if (model){
                   def keys = model.keySet()
                   if(keys.size() == 1){
                       model = model.get(keys.toArray()[0])
                   }
                   switch(request.format){
                       case 'json':
                            render text:model as JSON, contentType: "application/json"
                            break
                       case 'xml':
                            render text:model as XML, contentType: "application/xml"
                            break
                       default:
                            render status: 406
                            break
                   }
                   return false

                }
                return true
            }
        }
    }

    private boolean isControllerApiRenderable(def controller) {
        return ApplicationHolder.application.mainContext.getBean(controller.fullName).class.isAnnotationPresent(ApiEnabled)
    }

    private boolean isActionApiRenderable(def action) {
        return action.isAnnotationPresent(ApiEnabled)
    }

    private boolean isActionApiCorrectVersion(def action, def version) {
        Collection<ApiVersion> versionAnnotations = action.annotations.findAll {
            it instanceof ApiVersion
        }
        boolean isCorrectVersion = false
        for(versionAnnotation in versionAnnotations){
            if(versionAnnotation.value().find { it == version }){
                isCorrectVersion = true
                break
            }
        }
        return isCorrectVersion
    }

Сначала фильтр аутентифицирует любой запрос, входящий в (неполный заглушку), затем проверяет, имеете ли вы доступ к контроллеру и действие через api и что версия api поддерживается для данного действия. Если все эти условия выполнены, то он продолжает преобразовывать модель в json или xml.

Пользовательские аннотации

@Target({ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiEnabled {

}

Это сообщает ApiFilter, если заданному контроллеру или действию grails разрешено выводить данные xml/json. Поэтому, если аннотация @ApiEnabled находится на контроллере или уровне действия, ApiFilter продолжит преобразование json/xml

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

Я не совсем уверен, нужна ли мне эта аннотация, но, пожалуйста, добавьте ее здесь ради аргумента. В этой аннотации приводится информация о том, какие версии api поддерживают данное действие. поэтому, если действие поддерживает api версии 0.2 и 0.3, но 0.1 было поэтапно отменено, то все запросы к/api/0.1/не будут выполняться при этом действии. и если мне нужен более высокий уровень контроля над версией api, я всегда могу сделать простой оператор if или switch, например:

if(params.version == '0.2'){
   //do something slightly different 
} else {
  //do the default
}

ApiMarshaller

class ApiMarshaller implements ObjectMarshaller<Converter>{

    private final static CONVERT_TO_PROPERTY = 'toAPI'

    public boolean supports(Object object) {
        return getConverterClosure(object) != null
    }

    public void marshalObject(Object object, Converter converter) throws ConverterException {
        Closure cls = getConverterClosure(object)

        try {
            Object result = cls(object)
            converter.lookupObjectMarshaller(result).marshalObject(result,converter)
        }
        catch(Throwable e) {
            throw e instanceof ConverterException ? (ConverterException)e :
                new ConverterException("Error invoking ${CONVERT_TO_PROPERTY} method of object with class " + object.getClass().getName(),e);
        }
    }

    protected Closure getConverterClosure(Object object) {
        if(object){
            def overrideClosure = object.metaClass?.getMetaMethod(CONVERT_TO_PROPERTY)?.closure
            if(!overrideClosure){
                return object.metaClass?.hasProperty(object,CONVERT_TO_PROPERTY)?.getProperty(object)
            }
            return overrideClosure
        }
        return null
    }
}

Этот класс зарегистрирован как объектMarshaller как для конвертеров XML, так и для JSON. Он проверяет, имеет ли объект свойство toAPI. Если это так, он будет использовать это для маршалирования объекта. toAPI также можно переопределить через MetaClass, чтобы разрешить другую стратегию рендеринга. (ex версия 0.1 отображает объект по-другому, чем версия 0.2)

Bootstrap.. связывая все это

log.info "setting json/xml marshalling for api"

def apiMarshaller = new ApiMarshaller()

JSON.registerObjectMarshaller(apiMarshaller)
XML.registerObjectMarshaller(apiMarshaller)

Это все, что нужно сделать, чтобы использовать новую стратегию маршаллинга.

Пример класса домена

class Sample {
  String sampleText

  static toAPI = {[
    id:it.id,
    value:it.sampleText,
    version:it.version
  ]}
}

Простой класс домена, который показывает объявление образца API

Контроллер образца

@ApiEnabled
class SampleController {

    static allowedMethods = [list: "GET"]

    @ApiVersion(['0.2'])
    def list = {
        def samples = Sample.list()
        return [samples:samples]
    }

}

Это простое действие при доступе через api затем возвращает формат xml или json, который может или не может быть определен Sample.toAPI(). Если параметр toAPI не определен, тогда он будет использовать маршаллеры-преобразователи grails по умолчанию.

Итак, вот оно. Ребята, что вы думаете? он гибкий в соответствии с моим первоначальным вопросом? Ребята, вы видите какие-либо проблемы с этим дизайном или потенциальными проблемами производительности?

Ответ 2

Подождите, если вам все еще нужно использовать действие для веб-интерфейса, результат будет Map.

Если бы мне захотелось, чтобы API-вызов возвратил List, я бы добавил аннотацию @ApiListResult('dunnoInstanceList') к действию, и в вызове API просто возьмем данный параметр из результата действия.

Или даже просто @ApiListResult и выберите ключ Map, который endsWith('InstanceList').

В любом случае, управление версиями будет сложным, если вы собираетесь повторно использовать функции 2.0 контроллеров для обслуживания 1.0 запросов. Я бы добавил еще пару аннотаций, например @Since('2.0'), а для измененных подписей - @Till('1.1') и @ActionVersion('list', '1.0') def list10 = {...} - для действия, которое сохраняет устаревшую подпись.

Ответ 3

Самый гибкий api - это тот, который не привязан непосредственно к вашим контроллерам и разделяет беспокойство. Apis в потоке запроса/ответа представляет собой проблему архитектурной перекрестной репликации и, таким образом, разделяет конфигурацию, безопасность и обработку с помощью инструментов и экземпляров.

Таким образом, api необходимо разделить в качестве уровня связи, конечная точка должна быть разрешена на уровне связи (что позволяет перенаправлять внутри системы BACK на уровень связи), config/security необходимо использовать совместно используемым компонентом, который может быть перезагружаемый/синхронизированный по инструментам и экземплярам (не для того, чтобы отделить управление версиями приложений от версии api) и т.д.

Инструмент Grails api - яркий пример этого разделения, позволяющий использовать гораздо более масштабируемую архитектуру. Я бы посоветовал поиграть с ним и получить удобство по мере его продвижения.