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, '&') 343 .replace(/>/g, '>') 344 .replace(/</g, '<') 345 .replace(/"/g, '"') 346 .replace(/'/g, ''') 347 .replace(/ /g, ' '); 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 + ' '; 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