1// Copyright 2010 Google Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15
16/**
17 * @fileoverview Render form appropriate for RPC method.
18 * @author rafek@google.com (Rafe Kaplan)
19 */
20
21
22var FORM_VISIBILITY = {
23  SHOW_FORM: 'Show Form',
24  HIDE_FORM: 'Hide Form'
25};
26
27
28var LABEL = {
29  OPTIONAL: 'OPTIONAL',
30  REQUIRED: 'REQUIRED',
31  REPEATED: 'REPEATED'
32};
33
34
35var objectId = 0;
36
37
38/**
39 * Variants defined in protorpc/messages.py.
40 */
41var VARIANT = {
42  DOUBLE: 'DOUBLE',
43  FLOAT: 'FLOAT',
44  INT64: 'INT64',
45  UINT64: 'UINT64',
46  INT32: 'INT32',
47  BOOL: 'BOOL',
48  STRING: 'STRING',
49  MESSAGE: 'MESSAGE',
50  BYTES: 'BYTES',
51  UINT32: 'UINT32',
52  ENUM: 'ENUM',
53  SINT32: 'SINT32',
54  SINT64: 'SINT64'
55};
56
57
58/**
59 * Data structure used to represent a form to data element.
60 * @param {Object} field Field descriptor that form element represents.
61 * @param {Object} container Element that contains field.
62 * @return {FormElement} New object representing a form element.  Element
63 *     starts enabled.
64 * @constructor
65 */
66function FormElement(field, container) {
67  this.field = field;
68  this.container = container;
69  this.enabled = true;
70}
71
72
73/**
74 * Display error message in error panel.
75 * @param {string} message Message to display in panel.
76 */
77function error(message) {
78  $('<div>').appendTo($('#error-messages')).text(message);
79}
80
81
82/**
83 * Display request errors in error panel.
84 * @param {object} XMLHttpRequest object.
85 */
86function handleRequestError(response) {
87  var contentType = response.getResponseHeader('content-type');
88  if (contentType == 'application/json') {
89      var response_error = $.parseJSON(response.responseText);
90      var error_message = response_error.error_message;
91      if (error.state == 'APPLICATION_ERROR' && error.error_name) {
92          error_message = error_message + ' (' + error.error_name + ')';
93      }
94  } else {
95      error_message = '' + response.status + ': ' + response.statusText;
96  }
97
98  error(error_message);
99}
100
101
102/**
103 * Send JSON RPC to remote method.
104 * @param {string} path Path of service on originating server to send request.
105 * @param {string} method Name of method to invoke.
106 * @param {Object} request Message to send as request.
107 * @param {function} on_success Function to call upon successful request.
108 */
109function sendRequest(path, method, request, onSuccess) {
110  $.ajax({url: path + '.' + method,
111          type: 'POST',
112          contentType: 'application/json',
113          data: $.toJSON(request),
114          dataType: 'json',
115          success: onSuccess,
116          error: handleRequestError
117         });
118}
119
120
121/**
122 * Create callback that enables and disables field element when associated
123 * checkbox is clicked.
124 * @param {Element} checkbox Checkbox that will be clicked.
125 * @param {FormElement} form Form element that will be toggled for editing.
126 * @param {Object} disableMessage HTML element to display in place of element.
127 * @return Callback that is invoked every time checkbox is clicked.
128 */
129function toggleInput(checkbox, form, disableMessage) {
130  return function() {
131    var checked = checkbox.checked;
132    if (checked) {
133      buildIndividualForm(form);
134      form.enabled = true;
135      disableMessage.hide();
136    } else {
137      form.display.empty();
138      form.enabled = false;
139      disableMessage.show();
140    }
141  };
142}
143
144
145/**
146 * Build an enum field.
147 * @param {FormElement} form Form to build element for.
148 */
149function buildEnumField(form) {
150  form.descriptor = enumDescriptors[form.field.type_name];
151  form.input = $('<select>').
152      appendTo(form.display);
153
154  $('<option>').
155      appendTo(form.input).attr('value', '').
156      text('Select enum');
157  $.each(form.descriptor.values, function(index, enumValue) {
158      option = $('<option>');
159      option.
160          appendTo(form.input).
161          attr('value', enumValue.name).
162          text(enumValue.name);
163      if (enumValue.number == form.field.default_value) {
164        option.attr('selected', 1);
165      }
166  });
167}
168
169
170/**
171 * Build nested message field.
172 * @param {FormElement} form Form to build element for.
173 */
174function buildMessageField(form) {
175  form.table = $('<table border="1">').appendTo(form.display);
176  buildMessageForm(form, messageDescriptors[form.field.type_name]);
177}
178
179
180/**
181 * Build boolean field.
182 * @param {FormElement} form Form to build element for.
183 */
184function buildBooleanField(form) {
185  form.input = $('<input type="checkbox">');
186  form.input[0].checked = Boolean(form.field.default_value);
187}
188
189
190/**
191 * Build text field.
192 * @param {FormElement} form Form to build element for.
193 */
194function buildTextField(form) {
195  form.input = $('<input type="text">');
196  form.input.
197      attr('value', form.field.default_value || '');
198}
199
200
201/**
202 * Build individual input element.
203 * @param {FormElement} form Form to build element for.
204 */
205function buildIndividualForm(form) {
206  form.required = form.label == LABEL.REQUIRED;
207
208  if (form.field.variant == VARIANT.ENUM) {
209    buildEnumField(form);
210  } else if (form.field.variant == VARIANT.MESSAGE) {
211    buildMessageField(form);
212  } else if (form.field.variant == VARIANT.BOOL) {
213    buildBooleanField(form);
214  } else {
215    buildTextField(form);
216  }
217
218  form.display.append(form.input);
219
220  // TODO: Handle base64 encoding for BYTES field.
221  if (form.field.variant == VARIANT.BYTES) {
222    $("<i>use base64 encoding</i>").appendTo(form.display);
223  }
224}
225
226
227/**
228 * Add repeated field.  This function is called when an item is added
229 * @param {FormElement} form Repeated form element to create item for.
230 */
231function addRepeatedFieldItem(form) {
232  var row = $('<tr>').appendTo(form.display);
233  subForm = new FormElement(form.field, row);
234  form.fields.push(subForm);
235  buildFieldForm(subForm, false);
236}
237
238
239/**
240 * Build repeated field.  Contains a button that can be used for adding new
241 * items.
242 * @param {FormElement} form Form to build element for.
243 */
244function buildRepeatedForm(form) {
245  form.fields = [];
246  form.display = $('<table border="1" width="100%">').
247      appendTo(form.container);
248  var header_row = $('<tr>').appendTo(form.display);
249  var header = $('<td colspan="3">').appendTo(header_row);
250  var add_button = $('<button>').text('+').appendTo(header);
251
252  add_button.click(function() {
253    addRepeatedFieldItem(form);
254  });
255}
256
257
258/**
259 * Build a form field.  Populates form content with values required by
260 * all fields.
261 * @param {FormElement} form Repeated form element to create item for.
262 * @param allowRepeated {Boolean} Allow display of repeated field.  If set to
263 *     to true, will treat repeated fields as individual items of a repeated
264 *     field and render it as an individual field.
265 */
266function buildFieldForm(form, allowRepeated) {
267  // All form fields are added to a row of a table.
268  var inputData = $('<td>');
269
270  // Set name.
271  if (allowRepeated) {
272    var nameData = $('<td>');
273    nameData.text(form.field.name + ':');
274    form.container.append(nameData);
275  }
276
277  // Set input.
278  form.repeated = form.field.label == LABEL.REPEATED;
279  if (allowRepeated && form.repeated) {
280    inputData.attr('colspan', '2');
281    buildRepeatedForm(form);
282  } else {
283    if (!allowRepeated) {
284        inputData.attr('colspan', '2');
285    }
286
287    form.display = $('<div>');
288
289    var controlData = $('<td>');
290    if (form.field.label != LABEL.REQUIRED && allowRepeated) {
291        form.enabled = false;
292      var checkbox_id = 'checkbox-' + objectId;
293      objectId++;
294      $('<label for="' + checkbox_id + '">Enabled</label>').appendTo(controlData);
295      var checkbox = $('<input id="' + checkbox_id + '" type="checkbox">').appendTo(controlData);
296      var disableMessage = $('<div>').appendTo(inputData);
297      checkbox.change(toggleInput(checkbox[0], form, disableMessage));
298    } else {
299      buildIndividualForm(form);
300    }
301
302    if (form.repeated) {
303      // TODO: Implement deletion of repeated items.  Needs to delete
304      // from DOM and also delete from form model.
305    }
306
307    form.container.append(controlData);
308  }
309
310  inputData.append(form.display);
311  form.container.append(inputData);
312}
313
314
315/**
316 * Top level function for building an entire message form.  Called once at form
317 * creation and may be called again for nested message fields.  Constructs a
318 * a table and builds a row for each sub-field.
319 * @params {FormElement} form Form to build message form for.
320 */
321function buildMessageForm(form, messageType) {
322  form.fields = [];
323  form.descriptor = messageType;
324  if (messageType.fields) {
325    $.each(messageType.fields, function(index, field) {
326      var row = $('<tr>').appendTo(form.table);
327      var fieldForm = new FormElement(field, row);
328      fieldForm.parent = form;
329      buildFieldForm(fieldForm, true);
330      form.fields.push(fieldForm);
331    });
332  }
333}
334
335
336/**
337 * HTML Escape a string
338 */
339function htmlEscape(value) {
340  if (typeof(value) == "string") {
341    return value
342      .replace(/&/g, '&amp;')
343      .replace(/>/g, '&gt;')
344      .replace(/</g, '&lt;')
345      .replace(/"/g, '&quot;')
346      .replace(/'/g, '&#39;')
347      .replace(/ /g, '&nbsp;');
348  } else {
349    return value;
350  }
351}
352
353
354/**
355 * JSON formatted in HTML for display to users.  This method recursively calls
356 * itself to render sub-JSON objects.
357 * @param {Object} value JSON object to format for display.
358 * @param {Integer} indent Indentation level for object being displayed.
359 * @return {string} Formatted JSON object.
360 */
361function formatJSON(value, indent) {
362  var indentation = '';
363  for (var index = 0; index < indent; ++index) {
364    indentation = indentation + '&nbsp;&nbsp;';
365  }
366  var type = typeof(value);
367
368  var result = '';
369
370  if (type == 'object') {
371    if (value.constructor === Array) {
372      result += '[<br>';
373      $.each(value, function(index, item) {
374        result += indentation + formatJSON(item, indent + 1) + ',<br>';
375      });
376      result += indentation + ']';
377    } else {
378      result += '{<br>';
379      $.each(value, function(name, item) {
380        result += (indentation + htmlEscape(name) + ': ' +
381                   formatJSON(item, indent + 1) + ',<br>');
382      });
383      result += indentation + '}';
384    }
385  } else {
386    result += htmlEscape(value);
387  }
388
389  return result;
390}
391
392
393/**
394 * Construct array from repeated form element.
395 * @param {FormElement} form Form element to build array from.
396 * @return {Array} Array of repeated elements read from input form.
397 */
398function fromRepeatedForm(form) {
399  var values = [];
400  $.each(form.fields, function(index, subForm) {
401    values.push(fromIndividualForm(subForm));
402  });
403  return values;
404}
405
406
407/**
408 * Construct value from individual form element.
409 * @param {FormElement} form Form element to get value from.
410 * @return {string, Float, Integer, Boolean, object} Value extracted from
411 *     individual field.  The type depends on the field variant.
412 */
413function fromIndividualForm(form) {
414  switch(form.field.variant) {
415  case VARIANT.MESSAGE:
416    return fromMessageForm(form);
417    break;
418
419  case VARIANT.DOUBLE:
420  case VARIANT.FLOAT:
421    return parseFloat(form.input.val());
422
423  case VARIANT.BOOL:
424    return form.input[0].checked;
425    break;
426
427  case VARIANT.ENUM:
428  case VARIANT.STRING:
429  case VARIANT.BYTES:
430    return form.input.val();
431
432  default:
433    break;
434  }
435  return parseInt(form.input.val(), 10);
436}
437
438
439/**
440 * Extract entire message from a complete form.
441 * @param {FormElement} form Form to extract message from.
442 * @return {Object} Fully populated message object ready to transmit
443 *     as JSON message.
444 */
445function fromMessageForm(form) {
446  var message = {};
447  $.each(form.fields, function(index, subForm) {
448    if (subForm.enabled) {
449      var subMessage = undefined;
450      if (subForm.field.label == LABEL.REPEATED) {
451        subMessage = fromRepeatedForm(subForm);
452      } else {
453        subMessage = fromIndividualForm(subForm);
454      }
455
456      message[subForm.field.name] = subMessage;
457    }
458  });
459
460  return message;
461}
462
463
464/**
465 * Send form as an RPC.  Extracts message from root form and transmits to
466 * originating ProtoRPC server.  Response is formatted as JSON and displayed
467 * to user.
468 */
469function sendForm() {
470  $('#error-messages').empty();
471  $('#form-response').empty();
472  message = fromMessageForm(root_form);
473  if (message === null) {
474    return;
475  }
476
477  sendRequest(servicePath, methodName, message, function(response) {
478    $('#form-response').html(formatJSON(response, 0));
479    hideForm();
480  });
481}
482
483
484/**
485 * Reset form to original state.  Deletes existing form and rebuilds a new
486 * one from scratch.
487 */
488function resetForm() {
489  var panel = $('#form-panel');
490  var serviceType = serviceMap[servicePath];
491  var service = serviceDescriptors[serviceType];
492
493  panel.empty();
494
495  function formGenerationError(message) {
496    error(message);
497    panel.html('<div class="error-message">' +
498               'There was an error generating the service form' +
499               '</div>');
500  }
501
502    // Find method.
503  var requestTypeName = null;
504  $.each(service.methods, function(index, method) {
505    if (method.name == methodName) {
506        requestTypeName = method.request_type;
507    }
508  });
509
510  if (!requestTypeName) {
511    formGenerationError('No such method definition for: ' + methodName);
512    return;
513  }
514
515  requestType = messageDescriptors[requestTypeName];
516  if (!requestType) {
517    formGenerationError('No such message-type: ' + requestTypeName);
518    return;
519  }
520
521  var root = $('<table border="1">').
522      appendTo(panel);
523
524  root_form = new FormElement(null, null);
525  root_form.table = root;
526  buildMessageForm(root_form, requestType);
527  $('<button>').appendTo(panel).text('Send Request').click(sendForm);
528  $('<button>').appendTo(panel).text('Reset').click(resetForm);
529}
530
531
532/**
533 * Hide main RPC form from user.  The information in the form is preserved.
534 * Called after RPC to server is completed.
535 */
536function hideForm() {
537  var expander = $('#form-expander');
538  var formPanel = $('#form-panel');
539  formPanel.hide();
540  expander.text(FORM_VISIBILITY.SHOW_FORM);
541}
542
543
544/**
545 * Toggle the display of the main RPC form.  Called when form expander button
546 * is clicked.
547 */
548function toggleForm() {
549  var expander = $('#form-expander');
550  var formPanel = $('#form-panel');
551  if (expander.text() == FORM_VISIBILITY.HIDE_FORM) {
552    hideForm();
553  } else {
554    formPanel.show();
555    expander.text(FORM_VISIBILITY.HIDE_FORM);
556  }
557}
558
559
560/**
561 * Create form.  Called after all service information and file sets have been
562 * loaded.
563 */
564function createForm() {
565  $('#form-expander').click(toggleForm);
566  resetForm();
567}
568
569
570/**
571 * Display available services and their methods.
572 */
573function showMethods() {
574  var methodSelector = $('#method-selector');
575  if (serviceMap) {
576    $.each(serviceMap, function(serviceName) {
577      var descriptor = serviceDescriptors[serviceMap[serviceName]];
578      methodSelector.append(descriptor.name);
579      var block = $('<blockquote>').appendTo(methodSelector);
580      $.each(descriptor.methods, function(index, method) {
581        var url = (formPath + '?path=' + serviceName +
582                   '&method=' + method.name);
583        var label = serviceName + '.' + method.name;
584        $('<a>').attr('href', url).text(label).appendTo(block);
585        $('<br>').appendTo(block);
586      });
587    });
588  }
589}
590
591
592/**
593 * Populate map of fully qualified message names to descriptors.  This method
594 * is called recursively to populate message definitions nested within other
595 * message definitions.
596 * @param {Object} messages Array of message descriptors as returned from the
597 *     RegistryService.get_file_set call.
598 * @param {string} container messages may be an Array of messages nested within
599 *     either a FileDescriptor or a MessageDescriptor.  The container is the
600 *     fully qualified name of the file descriptor or message descriptor so
601 *     that the fully qualified name of the messages in the list may be
602 *     constructed.
603 */
604function populateMessages(messages, container) {
605  if (messages) {
606    $.each(messages, function(messageIndex, message) {
607      var messageName = container + '.' + message.name;
608      messageDescriptors[messageName] = message;
609
610      if (message.message_types) {
611        populateMessages(message.message_types, messageName);
612      }
613
614      if (message.enum_types) {
615        $.each(message.enum_types, function(enumIndex, enumerated) {
616          var enumName = messageName + '.' + enumerated.name;
617          enumDescriptors[enumName] = enumerated;
618        });
619      }
620    });
621  }
622}
623
624
625/**
626 * Populates all descriptors from a FileSet descriptor.  Each of the three
627 * descriptor collections (service, message and enum) map the fully qualified
628 * name of a definition to it's descriptor.
629 */
630function populateDescriptors(file_set) {
631  serviceDescriptors = {};
632  messageDescriptors = {};
633  enumDescriptors = {};
634  $.each(file_set.files, function(index, file) {
635    if (file.service_types) {
636      $.each(file.service_types, function(serviceIndex, service) {
637        var serviceName = file['package'] + '.' + service.name;
638        serviceDescriptors[serviceName] = service;
639      });
640    }
641
642    populateMessages(file.message_types, file['package']);
643  });
644}
645
646
647/**
648 * Load all file sets from ProtoRPC registry service.
649 * @param {function} when_done Called after all file sets are loaded.
650 */
651function loadFileSets(when_done) {
652  var paths = [];
653  $.each(serviceMap, function(serviceName) {
654    paths.push(serviceName);
655  });
656
657  sendRequest(
658      registryPath,
659      'get_file_set',
660      {'names': paths},
661      function(response) {
662          populateDescriptors(response.file_set, when_done);
663          when_done();
664  });
665}
666
667
668/**
669 * Load all services from ProtoRPC registry service.  When services are
670 * loaded, will then load all file_sets from the server.
671 * @param {function} when_done Called after all file sets are loaded.
672 */
673function loadServices(when_done) {
674  sendRequest(
675      registryPath,
676      'services',
677      {},
678      function(response) {
679        serviceMap = {};
680        $.each(response.services, function(index, service) {
681          serviceMap[service.name] = service.definition;
682        });
683        loadFileSets(when_done);
684      });
685}
686