8
\$\begingroup\$

I'm working on the interactive API documentation of a RESTful dictionary service. The page should let testers try the different API calls, by playing with the parameters using a simple web form. Changes to the web form should trigger API calls with the appropriate parameters, display the JSON output, and a well-formatted curl command that's ready to run.

I'm using Backbone.js to implement the dynamic client-side behavior, and the following class structure:

  • App.FormView: a base View class, for common form controls, such as a dictionary selector and a Run button, to be used by different API endpoint tools

  • App.CurlView: a base View class, for common logic when rendering sample curl commands of different API endpoint, especially for building the common part of API URLs

  • App.FindByKeywordParams: a Model class, for capturing the parameters needed for making one of the /find/:method/:keyword type of API calls. Views can subscribe to change events to trigger refreshing themselves.

  • App.FindByKeywordFormView: a View class, extending App.FormView, adding custom form controls specialized for the /find/:method/:keyword type of API calls. It updates a App.FindByKeywordParams object.

  • App.FindByKeywordCurlView: a View class, extending App.CurlView, adding custom curl URL building logic specialized for the /find/:method/:keyword type of API calls. Updates to the App.FindByKeywordParams object it references trigger refreshing the view.

Implementation:

var App = window.App = {};

App.API_BASEURL = '/api/v1/dictionaries';

App.FormView = Backbone.View.extend({
    _events: {
        'click .run': 'runBtn',
        'change .dictionary': 'run'
    },
    _initialize: function () {
        this.dictionary = this.$('select[name="dictionary"]');
        this.output = this.$('.api-output');
    },
    runBtn: function (e) {
        e.preventDefault();
        this.run();
    },
    runOnEnter: function (e) {
        if (e.keyCode != 13) return;
        e.preventDefault();
        this.run();
    },
    run: function () {
        throw 'abstract method: subclass should implement!';
    },
    onApiSuccess: function (json) {
        this.$el.find('.api-error').addClass('api-error-hidden');

        this.output.empty();
        this.output.text(JSON.stringify(json, null, 2));
    },
    onApiError: function (url, jqXHR, textStatus, statusText) {
        var $apiError = this.$el.find('.api-error');
        $apiError.removeClass('api-error-hidden');
        $apiError.find('a').attr('href', url).text(url);
        $apiError.find('.statusNum').text(jqXHR.status);
        $apiError.find('.statusText').text(statusText);
    }
});

App.CurlView = Backbone.View.extend({
    initialize: function () {
        this.model.on('change', this.render, this);
        this.model.trigger('change');
    },
    events: {
        'click': 'selectEntireCurl'
    },
    selectEntireCurl: function () {
        var selection = window.getSelection();
        var range = document.createRange();
        range.selectNodeContents(this.el);
        selection.removeAllRanges();
        selection.addRange(range);
    },
    render: function () {
        this.$el.text(this.get_curl_cmd());
        return this;
    },
    get_baseurl: function () {
        return location.origin + App.API_BASEURL + '/' + this.model.get('dict_id');
    },
    get_quoted_url: function (url) {
        if (url.indexOf(' ') > -1) {
            return '"' + url + '"';
        }
        return url;
    },
    get_url_end_part: function () {
        throw 'abstract method: subclass should implement!';
    },
    get_extra_args: function () {
        return '';
    },
    get_curl_cmd: function () {
        return 'curl ' +
            this.get_quoted_url(this.get_baseurl() + '/' + this.get_url_end_part()) +
            this.get_extra_args();
    }
});

App.FindByKeywordParams = Backbone.Model.extend({
    defaults: {
        dict_id: 'wud',
        method: 'exact',
        keyword: 'explorez',
        similar: true
    }
});

App.FindByKeywordFormView = App.FormView.extend({
    el: '#find-by-keyword-form',
    events: function() {
      return _.extend({}, this._events, {
          'keypress .keyword': 'runOnEnter',
          'change .method': 'run',
          'change .keyword': 'run',
          'change .find-similar': 'run',
          'change .list-only': 'run'
      });
    },
    initialize: function () {
        this._initialize();
        this.keyword = this.$('.keyword');
        this.keyword.val(this.model.get('keyword'));
    },
    run: function () {
        var keyword = this.keyword.val();
        if (!keyword) return;
        this.runWithKeyword(keyword);
    },
    runWithKeyword: function (keyword) {
        var dictionary = this.dictionary.val();
        var method = this.$el.find('.method:checked').val();
        var similar = this.$el.find('.find-similar:checked').size() > 0;
        var list = this.$el.find('.list-only:checked').size() > 0;
        this.find(dictionary, method, keyword, similar, list);
    },
    find: function (dict_id, method, keyword, similar, list) {
        this.model.set({
            dict_id: dict_id,
            method: method,
            keyword: keyword,
            similar: similar,
            list: list
        });
        var url = App.API_BASEURL + "/" + dict_id + "/find/" + method + "/" + keyword;
        var extras = {};
        if (similar) {
            extras.similar = true;
        }
        if (list) {
            extras.list = true;
        }

        var _this = this;
        var success = function (json) {
            _this.onApiSuccess(json);
        };
        var error = function (jqXHR, textStatus, errorThrown) {
            _this.onApiError(url, jqXHR, textStatus, errorThrown);
        };

        $.ajax({
            url: url,
            data: extras,
            success: success,
            error: error
        });
    }
});

App.FindByKeywordCurlView = App.CurlView.extend({
    el: '#find-by-keyword-curl',
    get_url_end_part: function () {
        return 'find/' + this.model.get('method') + '/' + this.model.get('keyword');
    },
    get_extra_args: function () {
        var extras = '';
        var similar = this.model.get('similar');
        var list = this.model.get('list');
        if (similar || list) {
            extras += ' --get';
            if (similar) {
                extras += ' -d similar=1';
            }
            if (list) {
                extras += ' -d list=1';
            }
        }
        return extras;
    }
});

function onDomReady() {
    var findByKeywordParams = new App.FindByKeywordParams();
    new App.FindByKeywordCurlView({model: findByKeywordParams});
    var findByKeywordForm = new App.FindByKeywordFormView({model: findByKeywordParams});
    findByKeywordForm.run();
    findByKeywordForm.keyword.focus();
}

$(function () {
    onDomReady();
});

I'm new to Backbone.js, I would like a general review. Is there something I'm doing wrong, or something I could do better, simpler?

You can see the code in action here.

The full source code is hosted on GitHub.

\$\endgroup\$
10
\$\begingroup\$

Be consistent with your method naming; don't mix camelCase and snake_case, pick one and stick with it. I would recommend picking camelCase as there are a lot of JavaScript projects out there which use it as a convention, but feel free to go with whatever is most readable to you.

I would define your extras object as so; falsey values shouldn't be picked up on the server side code anyway:

var extras = {
    list: list,
    similar: similar
}

As Backbone depends on underscore, don't be afraid to tidy up your code with underscore's methods:

var success = _.bind(function(json) {
    this.onApiSuccess(json);
}, this);

Better yet, you can get rid of the proxy entirely:

$.ajax({
    url: url,
    data: extras,
    success: _.bind(this.onApiSuccess, this),
    error: _.bind(_.partial(this.onApiError, url), this)
});

Instead of string concatenation, underscore templating can make this more readable:

var url = _.template('<%- baseURL %>/<%- dictID %>/find/<%- method %>/<%- keyword %>', {
    baseURL: App.API_BASEURL,
    dictID: dict_id,
    method: method,
    keyword: keyword
});

Don't be afraid of borrowing methods; your _initialize method should be rewritten as initialize so that a later form view could use it, if it didn't need to implement some custom code. Then, instead of doing this._initialize() you can do this:

initialize: function() {
    App.FormView.prototype.initialize.apply(this);
    this.keyword = this.$('.keyword');
    this.keyword.val(this.model.get('keyword'));
}

Your references to this.$el.find() can simply be this.$(), which you've used, but not consistently. :)

Finally, you need to document your code so that you/another developer can know what it does in the future; I recommend JSDoc which can be easily integrated into a task runner/build system such as Grunt or gulp, or if not just use the CLI tool.

Hope this helps. :)

Edit: I've tested the _.bind and _.partial methods and they're working for me, you might want to make sure that you are running the latest version of underscore (upgrading to Backbone 1.0.x might be good too but that may require some additional code changes). Personally I've been using Lo-Dash as underscore instead as the library offers more features and is generally regarded to be more stable/consistent. You can also use the underscore build of Lo-Dash for a drop in replacement.

| improve this answer | |
\$\endgroup\$
  • \$\begingroup\$ Thanks Ben, it helps! Unfortunately, $.ajax({ success: this.onApiSuccess, ... }) doesn't work like that. Do you know if that's because I'm on Backbone 0.9? As for onApiError, notice that I'm passing to it a url parameter, so I have to wrap it. Unless you know a way to dig out the URL from the AJAX response, if that's even possible. \$\endgroup\$ – janos Aug 18 '14 at 19:50
  • 1
    \$\begingroup\$ What happens if you were to try binding it to the view? $.ajax({ success: _.bind(this.onApiSuccess, this) }); - I've used this with other methods in the past and it has worked for me. Oh yup, see the point with onApiError. Have you tried using _.partial? Might be relevant: { error: _.bind(_.partial(this.onApiError, url), this) } underscorejs.org/#partial \$\endgroup\$ – Ben Aug 18 '14 at 20:24
  • \$\begingroup\$ falsey values shouldn't be picked up on the server side code anyway: I might be misunderstanding something but from a security point of view you defiantly want to check for weird values on the server side. \$\endgroup\$ – user45891 Aug 18 '14 at 21:03
  • \$\begingroup\$ ^ Yup, exactly; the server will validate the request, therefore sending a key with undefined/false as a value is OK assuming that you wrote the server code to handle false values (and you should!) - it will usually mean that the client hasn't provided a value for that key so the server should ignore it, or at least that has been my experience. \$\endgroup\$ – Ben Aug 18 '14 at 21:17
  • \$\begingroup\$ @janos I tested the _.bind/_.partial methods this morning and its working for me. :) \$\endgroup\$ – Ben Aug 19 '14 at 8:07

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.