Skip to content

Instantly share code, notes, and snippets.

@netshade
Last active September 26, 2022 20:11
Show Gist options
  • Save netshade/e7af325fcb0675804495ced9d733dc48 to your computer and use it in GitHub Desktop.
Save netshade/e7af325fcb0675804495ced9d733dc48 to your computer and use it in GitHub Desktop.
Open311 HTML - JS Test Harness
<!DOCTYPE html>
<html>
<head>
<title>Open311 Test Client</title>
<style>
html {
max-width: 140ch;
padding: 3em 1em;
margin: auto;
line-height: 1.75;
font-size: 1.25em;
}
h1,h2,h3,h4,h5,h6 {
margin: 3em 0 1em;
}
p,ul,ol,legend,label,span {
color: #1d1d1d;
font-family: sans-serif;
}
fieldset fieldset legend {
font-size: 0.85em;
}
#messages {
left: 0px;
right: 0px;
top: 0px;
height: 50px;
border: solid 1px #cccc;
padding: 4px;
margin: 4px;
}
#messages .message {
width: 100%;
}
#messages .message.error {
color: red;
}
#messages .message.success {
color: green;
}
fieldset.config div, fieldset.action .action_panel div {
width: 100%;
}
fieldset.config div label, fieldset.action .action_panel div label {
font-size: 0.75em;
width: 10%;
margin-top: 4px;
vertical-align: top;
display: inline-block;
}
fieldset .button_group {
border-top: dashed 1px gray;
margin-top: 8px;
margin-bottom: 4px;
padding-top: 8px;
}
.hidden { display: none; }
#result li {
margin-bottom: 10px;
}
#result li .name {
display: inline-block;
margin-right: 8px;
}
#result li .showRaw {
display: inline-block;
padding: 1px;
border: solid 1px red;
background-color: black;
color: white;
cursor: pointer;
}
</style>
<script type="text/javascript">
class Open311Client {
constructor(endpoint, jurisdictionId, additionalHeaders) {
this.endpoint = endpoint;
this.jurisdictionId = jurisdictionId;
this.additionalHeaders = additionalHeaders;
}
async serviceList() {
const params = this.defaultSearchParams();
const url = [this.endpoint, "services.json?" + params.toString()].join("/");
const response = await this.fetch(url);
const result = await response.json();
if(!Array.isArray(result)){
return null;
} else {
return result;
}
}
async serviceDefinition(serviceCode, showExtensions) {
if(serviceCode == null){
throw new Error("Unknown type for serviceCode");
}
const params = this.defaultSearchParams();
if(showExtensions){
params.append("extensions", true);
}
const url = [this.endpoint, "services", serviceCode + ".json?" + params.toString()].join("/");
const response = await this.fetch(url);
const result = await response.json();
if(!result){
return null;
} else {
return result;
}
}
async serviceRequests(serviceRequestId, serviceCode, startDate, endDate, status, showExtensions){
const params = this.defaultSearchParams();
if(serviceRequestId != null){
params.append("service_request_id", serviceRequestId);
}
if(serviceCode != null){
params.append("service_code", serviceCode);
}
if(startDate != null){
params.append("start_date", startDate);
}
if(endDate != null){
params.append("end_date", endDate);
}
if(status != null){
params.append("status", status);
}
if(showExtensions){
params.append("extensions", true);
}
const url = [this.endpoint, "requests.json?" + params.toString()].join("/");
const response = await this.fetch(url);
const result = await response.json();
if(!Array.isArray(result)){
return null;
} else {
return result;
}
}
async serviceRequestIDFromToken(token){
if(token == null){
throw new Error("Unknown type for token");
}
const searchParams = this.defaultSearchParams();
const url = [this.endpoint, "tokens", token + ".json?" + searchParams.toString()].join("/");
const response = await this.fetch(url);
const result = await response.json();
if(!Array.isArray(result)){
return null;
} else {
return result;
}
}
async createServiceRequest(apiKey, serviceCode, attributes, lat, long, addressString, addressId, email, deviceId, accountId, firstName, lastName, phone, description, mediaUrl){
if(apiKey == null){
throw new Error("Unknown type for apiKey");
}
if(serviceCode == null){
throw new Error("Unknown type for serviceCode");
}
const url = [this.endpoint, "requests.json"].join("/");
const data = this.defaultFormData();
data.append("api_key", apiKey);
data.append("service_code", serviceCode);
this.serializeObjectIntoFormData(data, "attribute", attributes);
if(lat != null){
data.append("lat", lat);
}
if(long != null){
data.append("long", long);
}
if(addressString != null){
data.append("address_string", addressString);
}
if(addressId != null){
data.append("address_id", addressId);
}
if(email != null){
data.append("email", email);
}
if(deviceId != null){
data.append("device_id", deviceId);
}
if(accountId != null){
data.append("account_id", accountId);
}
if(firstName != null){
data.append("first_name", firstName);
}
if(lastName != null){
data.append("last_name", lastName);
}
if(phone != null){
data.append("phone", phone);
}
if(description != null){
data.append("description", description);
}
if(mediaUrl != null){
data.append("media_url", mediaUrl);
}
const response = await this.fetch(url, "post", data);
const result = await response.json();
if(!result){
return null;
} else {
return result;
}
}
async fetch(url, method = "get", body = null) {
return await fetch(url, { method, body, headers: this.defaultHeaders()});
}
serializeObjectIntoFormData(formData, keyName, object) {
if(Array.isArray(object)) {
const subName = keyName + "[]";
for(const value of object){
this.serializeObjectIntoFormData(formData, subName, value);
}
} else if(typeof object === "object"){
for(const key of Object.keys(object)){
const value = object[key];
if(value != null){
const subName = keyName + "[" + key + "]";
this.serializeObjectIntoFormData(formData, subName, value);
}
}
} else {
formData.append(keyName, object);
}
}
defaultSearchParams() {
const searchParams = new URLSearchParams();
if(this.jurisdictionId != null){
searchParams.append("jurisdiction_id", this.jurisdictionId);
}
return searchParams;
}
defaultFormData() {
const formData = new FormData();
if(this.jurisdictionId != null){
formData.append("jurisdiction_id", this.jurisdictionId);
}
return formData;
}
defaultHeaders() {
return {
"Accept": "application/json",
...this.additionalHeaders
};
}
}
let DISCOVERY_RESPONSE = null;
function clearNode(parent){
for(const node of parent.childNodes){
node.remove();
}
}
function emptyStringBecomesUndefined(value){
if(typeof value === "undefined") {
return value;
} else if(typeof value === "string"){
const valueTrimmed = value.trim();
if(valueTrimmed.length === 0){
return undefined;
} else {
return value;
}
} else {
return value;
}
}
function result_to_li_node(json_object, result_name) {
const li = document.createElement("li");
const name = document.createElement("span");
name.classList.add("name");
name.innerText = result_name;
const showRaw = document.createElement("a");
showRaw.classList.add("showRaw");
showRaw.innerText = "JSON";
const raw = document.createElement("code");
raw.innerText = JSON.stringify(json_object, null, 2);
const linkBlock = document.createElement("div");
const codeBlock = document.createElement("div");
linkBlock.appendChild(name);
linkBlock.appendChild(showRaw);
codeBlock.appendChild(raw);
codeBlock.classList.add("hidden");
li.appendChild(linkBlock);
li.appendChild(codeBlock);
showRaw.addEventListener("click", () => codeBlock.classList.toggle("hidden"));
return li;
}
function display_result(json_object_or_array, property_name_or_function){
if(!Array.isArray(json_object_or_array)){
json_object_or_array = [json_object_or_array];
}
const result = document.getElementById("result");
clearNode(result);
const ul = document.createElement("ul");
const p = (o) => {
if(typeof property_name_or_function === "function"){
return property_name_or_function(o);
} else if(typeof property_name_or_function === "string"){
return o[property_name_or_function];
} else {
throw new Error("Unknown type for property fetcher");
}
};
for(const obj of json_object_or_array){
ul.appendChild(result_to_li_node(obj, p(obj)));
}
result.appendChild(ul);
}
function display_error(api_function, error){
console.error(api_function + " ERROR", error);
page_message("error", error.toString());
}
function clear_messages(){
const messageDiv = document.getElementById("messages");
clearNode(messageDiv);
}
function page_message(type, message){
const messageDiv = document.getElementById("messages");
const newContent = document.createElement("span");
newContent.innerText = message;
newContent.classList.add("message");
newContent.classList.add(type);
messageDiv.appendChild(newContent);
console.log(messageDiv, newContent);
}
function open311_client_from_current_endpoint() {
const selected = document.getElementById("endpoint");
const url = selected.value;
if(url){
let additionalHeaders = emptyStringBecomesUndefined(document.getElementById("additional_headers").value);
if(additionalHeaders != null){
try{
additionalHeaders = JSON.parse(additionalHeaders);
} catch(err){
display_error("ENDPOINT", err);
return;
}
}
const jurisdictionId = emptyStringBecomesUndefined(document.getElementById("jurisdiction_id").value);
return new Open311Client(url, jurisdictionId, additionalHeaders);
} else {
return null;
}
}
function open311_client_connect() {
clear_messages();
let additionalHeaders = emptyStringBecomesUndefined(document.getElementById("additional_headers").value);
if(additionalHeaders != null){
try{
additionalHeaders = JSON.parse(additionalHeaders);
} catch(err){
display_error("DISCOVERY", err);
return;
}
}
const input = document.getElementById("discovery_endpoint");
const endpointAddress = input.value;
fetch(endpointAddress, { "headers": { "Accept": "application/json", ...additionalHeaders }}).then((result) => {
if(result.status === 200){
result.json().then((res) => {
DISCOVERY_RESPONSE = res;
}).catch((err) => {
console.error("JSON ERROR", err);
page_message("error", err.toString());
DISCOVERY_RESPONSE = null;
})
} else {
page_message("error", "Unexpected HTTP status " + result.status);
DISCOVERY_RESPONSE = null;
}
}).catch((err) => {
console.error("HTTP ERROR", err);
page_message("error", err.toString());
DISCOVERY_RESPONSE = null;
}).finally(() => {
open311_client_populate_endpoints();
});
}
function open311_client_force_discovery() {
clear_messages();
const forced_json = document.getElementById("discovery_json");
try {
const json = JSON.parse(forced_json.value);
DISCOVERY_RESPONSE = json;
} catch(err){
DISCOVERY_RESPONSE = null;
page_message("error", err);
}
open311_client_populate_endpoints();
}
function open311_client_populate_endpoints(){
const select = document.getElementById("endpoint");
clearNode(select);
if(!DISCOVERY_RESPONSE || !DISCOVERY_RESPONSE.endpoints){
return;
}
const endpoints = DISCOVERY_RESPONSE.endpoints;
for(const endpoint of endpoints){
const option = document.createElement("option");
option.value = endpoint.url;
option.text = endpoint.type + ": "+ endpoint.url;
select.appendChild(option);
}
}
function open311_client_service_list(){
const client = open311_client_from_current_endpoint();
if(client){
clear_messages();
client.serviceList().then((services) => {
display_result(services, "service_name");
}).catch((err) => {
display_error("SERVICE LIST", err);
})
}
}
function open311_client_service_definition_get() {
const client = open311_client_from_current_endpoint();
if(client){
clear_messages();
const code = emptyStringBecomesUndefined(document.getElementById("service_type_id").value);
const extensions = document.getElementById("service_type_extensions").checked;
client.serviceDefinition(code, extensions).then((service_def) => {
display_result(service_def, "service_code");
}).catch((err) => {
display_error("SERVICE DEFINITION", err);
});
}
}
function open311_client_service_requests_get(){
const client = open311_client_from_current_endpoint();
if(client){
clear_messages();
const service_request_id = emptyStringBecomesUndefined(document.getElementById("service_request_id").value);
const service_code = emptyStringBecomesUndefined(document.getElementById("service_code").value);
const start_date = emptyStringBecomesUndefined(document.getElementById("start_date").value);
const end_date = emptyStringBecomesUndefined(document.getElementById("end_date").value);
const status = emptyStringBecomesUndefined(document.getElementById("status").value);
const show_extensions = document.getElementById("service_requests_extensions").checked;
client.serviceRequests(service_request_id, service_code, start_date, end_date, status, show_extensions).then((service_requests) => {
display_result(service_requests, (service_request) => {
return service_request.service_code + ":" + (service_request.description || service_request.service_request_id);
});
}).catch((err) => {
display_error("SERVICE REQUESTS", err);
})
}
}
function open311_client_service_requests_get_id_from_token() {
const client = open311_client_from_current_endpoint();
if(client){
clear_messages();
const token = document.getElementById("service_request_token").value;
client.serviceRequestIDFromToken(token).then((service_request_id_results) => {
display_result(service_request_id_results, "service_request_id");
}).catch((err) => {
display_error("SERVICE REQUEST ID", err);
})
}
}
function open311_client_create_service_request() {
const client = open311_client_from_current_endpoint();
if(client){
clear_messages();
const apiKey = emptyStringBecomesUndefined(document.getElementById("new_api_key").value);
const serviceCode = emptyStringBecomesUndefined(document.getElementById("new_service_code").value);
const addressString = emptyStringBecomesUndefined(document.getElementById("new_address_string").value);
const addressId = emptyStringBecomesUndefined(document.getElementById("new_address_id").value);
const latitude = emptyStringBecomesUndefined(document.getElementById("new_lat").value);
const longitude = emptyStringBecomesUndefined(document.getElementById("new_long").value);
let attribute = emptyStringBecomesUndefined(document.getElementById("new_attribute").value);
if(typeof attribute === "string"){
try {
attribute = JSON.parse(attribute);
} catch(err){
display_error("NEW SERVICE REQUEST", err);
return;
}
}
const email = emptyStringBecomesUndefined(document.getElementById("new_email").value);
const phone = emptyStringBecomesUndefined(document.getElementById("new_phone").value);
const deviceId = emptyStringBecomesUndefined(document.getElementById("new_device_id").value);
const accountId = emptyStringBecomesUndefined(document.getElementById("new_account_id").value);
const firstName = emptyStringBecomesUndefined(document.getElementById("new_first_name").value);
const lastName = emptyStringBecomesUndefined(document.getElementById("new_last_name").value);
const description = emptyStringBecomesUndefined(document.getElementById("new_description").value);
const mediaUrl = emptyStringBecomesUndefined(document.getElementById("new_media_url").value);
client.createServiceRequest(apiKey,
serviceCode,
attribute,
latitude,
longitude,
addressString,
addressId,
email,
deviceId,
accountId,
firstName,
lastName,
phone,
description,
mediaUrl).then((create_result) => {
display_result(create_result, "token");
}).catch((err) => {
display_error("NEW SERVICE REQUEST", err);
});
}
}
function open311_show_action_panel() {
const panels = document.querySelectorAll(".action_panel");
for(const panel of panels){
if(!panel.classList.contains("hidden")){
panel.classList.add("hidden");
}
}
const selectedPanel = document.getElementById("action").value;
document.getElementById(selectedPanel).classList.remove("hidden");
}
window.addEventListener("load", () => {
open311_show_action_panel();
if(window.location.protocol.startsWith("file")){
document.getElementById("discovery_endpoint").value = "http://localhost:3000/open311/discovery.json";
} else if(window.location.protocol.startsWith("http")){
const protocol = window.location.protocol;
const host = window.location.hostname;
const port = window.location.port;
const defaultDiscoveryEndpoint = protocol + "//" + host + ":" + port + "/open311/discovery.json";
document.getElementById("discovery_endpoint").value = defaultDiscoveryEndpoint;
}
});
</script>
</head>
<body>
<fieldset class="config">
<legend>Configuration</legend>
<div>
<label for="discovery_endpoint">Discovery Endpoint</label>
<input type="text" id="discovery_endpoint" />
</div>
<div>
<label for="discovery_json">
Discovery JSON
<br /><sub>Output of discovery URL if not CORS compatible</sub>
</label>
<textarea id="discovery_json" rows="8" cols="80"></textarea>
</div>
<div>
<label for="endpoint">Endpoint</label>
<select id="endpoint"></select>
</div>
<div>
<label for="jurisdiction_id">Jurisdiction ID</label>
<input type="text" id="jurisdiction_id" />
</div>
<div>
<label for="additional_headers">Additional Headers<br /><sub>JSON of header names to values</sub></label>
<textarea id="additional_headers" rows="8" cols="80"></textarea>
</div>
<div class="button_group">
<input type="button" onclick="open311_client_connect();" value="Load from discovery endpoint" />
<input type="button" onclick="open311_client_force_discovery();" value="Force discovery from raw discovery JSON" />
</div>
</fieldset>
<div id="messages">
</div>
<fieldset class="action">
<legend>Actions</legend>
<select id="action" onchange="open311_show_action_panel();" ">
<option value="service_list">Get Service Type List</option>
<option value="service_definition_get">Get Service Definition</option>
<option value="service_requests_get">Get Service Requests</option>
<option value="service_requests_get_id_from_token">Get Service Request ID from Token</option>
<option value="service_request_create">Create Service Request</option>
</select>
<div id="service_list" class="action_panel hidden">
<input type="button" onclick="open311_client_service_list();" value="Fetch" />
</div>
<div id="service_definition_get" class="action_panel hidden">
<div>
<label for="service_type_id">Service Type ID</label>
<input type="text" id="service_type_id" />
</div>
<div>
<label for="service_type_extensions">Get Extended Attributes</label>
<input type="checkbox" id="service_type_extensions" checked />
</div>
<br />
<input type="button" onclick="open311_client_service_definition_get();" value="Fetch" />
</div>
<div id="service_requests_get" class="action_panel hidden">
<div>
<label for="service_request_id">Service Request ID<br /><sub>( comma delimited )</sub></label>
<input type="text" id="service_request_id" />
</div>
<div>
<label for="service_code">Service Code</label>
<input type="text" id="service_code" />
</div>
<div>
<label for="start_date">Start Date <br /><sub>(Must be format <code>YYYY-MM-DDTHH:MM::SSZ</code>)</sub></label>
<input type="text" id="start_date" />
</div>
<div>
<label for="end_date">End Date <br /><sub>(Must be format <code>YYYY-MM-DDTHH:MM::SSZ</code>)</sub></label>
<input type="text" id="end_date" />
</div>
<div>
<label for="status">Status <br /><sub>(must be <code>open</code> or <code>closed</code>)</sub></label>
<input type="text" id="status" />
</div>
<div>
<label for="service_requests_extensions">Get Extended Attributes</label>
<input type="checkbox" id="service_requests_extensions" checked />
</div>
<br />
<input type="button" onclick="open311_client_service_requests_get();" value="Fetch" />
</div>
<div id="service_requests_get_id_from_token" class="action_panel hidden">
<div>
<label for="service_request_token">Token</label>
<input type="text" id="service_request_token" />
</div>
<br />
<input type="button" onclick="open311_client_service_requests_get_id_from_token();" value="Fetch" />
</div>
<div id="service_request_create" class="action_panel hidden">
<div>
<label for="new_api_key">API Key</label>
<input type="text" id="new_api_key" />
</div>
<div>
<label for="new_service_code">Service Code</label>
<input type="text" id="new_service_code" />
</div>
<fieldset>
<legend>Location</legend>
<div>
<label for="new_address_string">Address String</label>
<input type="text" id="new_address_string" />
</div>
<div>
<label for="new_address_id">OR Address ID</label>
<input type="text" id="new_address_id" />
</div>
<div>
<label for="new_lat">OR Latitude and Longitude</label>
<input type="text" id="new_lat" placeholder="Latitude" /> <input type="text" id="new_long" placeholder="Longitude" /><br />
</div>
</fieldset>
<div>
<label for="new_attribute">
Attribute JSON
<br /><sub>JSON of key/value pairs to be placed in <code>attribute</code></sub>
</label>
<textarea rows="8" cols="80" id="new_attribute"></textarea>
</div>
<div>
<label for="new_email">Email</label>
<input type="text" id="new_email" />
</div>
<div>
<label for="new_phone">Phone</label>
<input type="text" id="new_phone" />
</div>
<div>
<label for="new_device_id">Device ID</label>
<input type="text" id="new_device_id" />
</div>
<div>
<label for="new_account_id">Account ID</label>
<input type="text" id="new_account_id" />
</div>
<div>
<label for="new_first_name">First Name</label>
<input type="text" id="new_first_name" />
</div>
<div>
<label for="new_last_name">Last Name</label>
<input type="text" id="new_last_name" />
</div>
<div>
<label for="new_description">Description</label>
<input type="text" id="new_description" />
</div>
<div>
<label for="new_media_url">Media URL</label>
<input type="text" id="new_media_url" />
</div>
<input type="button" onclick="open311_client_create_service_request();" value="Create" />
</div>
</fieldset>
<div id="result"></div>
</body>
@netshade
Copy link
Author

This is a zero dependency HTML file to exercise an Open311 endpoint. Note that most Open311 endpoints seem to have hostile CORS configurations that prevent use via JS.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment