-
Notifications
You must be signed in to change notification settings - Fork 24
Add Support for Cytoscape Web Service Apps to App Store #115
Description
Need a design on how to add Cytoscape Web Service Apps into the AppStore and figured it would be fun to ask chatgpt. here is the result:
Prompt:
How would you add support for cytoscape web service apps (https://github.com/cytoscape/cytoscape-web/wiki/Specification-for-Service-App-in-Cytoscape-Web-(draft-v2)) into the cytoscape appstore (https://apps.cytoscape.org) repo: https://github.com/cytoscape/appstore which is a django appAdd Support for Cytoscape Web Service Apps to App Store
This proposal adds Cytoscape Web Service App support to the Cytoscape App Store (repo: cytoscape/appstore), which is a Django web app.
The goal is to make Service Apps — per the Service App Specification (draft v2) — a first-class app type discoverable and installable via the App Store UI and via a machine-readable API endpoint for Cytoscape Web.
Overview
Goals
-
Submission & Validation
- Allow authors to register Cytoscape Web Service Apps via URL.
- Validate endpoints (
/and/status) according to the specification.
-
Discovery & Display
- Add a “Web (Service App)” category in the App Store UI.
- Show metadata, health, and endpoints on each app’s detail page.
-
API for Cytoscape Web
- Expose a JSON endpoint returning a list of
{ "url": "<service_url>" }for healthy apps.
- Expose a JSON endpoint returning a list of
-
Health Checks
- Background task to periodically verify
/status.
- Background task to periodically verify
Implementation
1. Enable in settings.py
diff --git a/appstore/settings.py b/appstore/settings.py
@@
INSTALLED_APPS = [
# ...
+ "serviceapps",
]2. Add to Root URLs
diff --git a/appstore/urls.py b/appstore/urls.py
@@
urlpatterns = [
path('admin/', admin.site.urls),
# existing routes...
+ path('', include('serviceapps.urls')),
]3. New Django App: serviceapps/
Directory structure:
serviceapps/
__init__.py
admin.py
apps.py
forms.py
models.py
urls.py
views.py
tests.py
templates/serviceapps/
submit.html
list.html
detail.html
management/commands/check_service_apps.py
migrations/0001_initial.py
apps.py
from django.apps import AppConfig
class ServiceappsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "serviceapps"
verbose_name = "Service Apps"models.py
from django.db import models
class ServiceApp(models.Model):
service_url = models.URLField(unique=True, help_text="Base URL of the service")
metadata = models.JSONField(default=dict, blank=True)
display_name = models.CharField(max_length=255, blank=True)
description = models.TextField(blank=True)
author = models.CharField(max_length=255, blank=True)
version = models.CharField(max_length=64, blank=True)
is_active = models.BooleanField(default=True)
last_checked = models.DateTimeField(null=True, blank=True)
last_status = models.CharField(max_length=32, default="unknown")
last_message = models.TextField(blank=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["display_name", "service_url"]
indexes = [
models.Index(fields=["is_active", "last_status"]),
models.Index(fields=["display_name"]),
]
def __str__(self):
return self.display_name or self.service_url
def as_app_definition(self):
return {"url": self.service_url}forms.py
from django import forms
class ServiceAppSubmitForm(forms.Form):
service_url = forms.URLField(
label="Service base URL",
help_text="Example: https://example.org/myservice"
)views.py
import json
import requests
from django.http import JsonResponse
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from django.views.generic import FormView, ListView, DetailView
from .forms import ServiceAppSubmitForm
from .models import ServiceApp
SPEC_TIMEOUT = 8
def _fetch_json(url):
r = requests.get(url, timeout=SPEC_TIMEOUT, headers={"Accept": "application/json"})
r.raise_for_status()
return r.json()
REQUIRED_TOP_KEYS = [
"name",
"description",
"version",
"parameters",
"serviceInputDefinition",
"cyWebActions",
"cyWebMenuItem",
]
class SubmitServiceAppView(FormView):
template_name = "serviceapps/submit.html"
form_class = ServiceAppSubmitForm
def get_success_url(self):
return reverse("serviceapps:list")
def form_valid(self, form):
base = form.cleaned_data["service_url"].rstrip("/")
try:
meta = _fetch_json(base + "/")
except Exception as e:
form.add_error("service_url", f"Could not fetch '/': {e}")
return self.form_invalid(form)
missing = [k for k in REQUIRED_TOP_KEYS if k not in meta]
if missing:
form.add_error("service_url", f"Missing keys: {', '.join(missing)}")
return self.form_invalid(form)
try:
status_json = _fetch_json(base + "/status")
except Exception as e:
form.add_error("service_url", f"Could not fetch '/status': {e}")
return self.form_invalid(form)
last_status = "ok" if status_json.get("status") == "ok" else "unavailable"
sa, _ = ServiceApp.objects.get_or_create(service_url=base)
sa.metadata = meta
sa.display_name = meta.get("name", "")
sa.description = meta.get("description", "")
sa.author = meta.get("author", "")
sa.version = meta.get("version", "")
sa.last_status = last_status
sa.last_checked = timezone.now()
sa.is_active = True
sa.save()
return super().form_valid(form)
class ServiceAppListView(ListView):
model = ServiceApp
template_name = "serviceapps/list.html"
context_object_name = "services"
def get_queryset(self):
qs = ServiceApp.objects.filter(is_active=True)
health = self.request.GET.get("health")
if health:
qs = qs.filter(last_status=health)
q = self.request.GET.get("q")
if q:
qs = qs.filter(display_name__icontains=q)
return qs
class ServiceAppDetailView(DetailView):
model = ServiceApp
template_name = "serviceapps/detail.html"
def service_apps_config(request):
qs = ServiceApp.objects.filter(is_active=True, last_status="ok")
return JsonResponse([sa.as_app_definition() for sa in qs], safe=False)urls.py
from django.urls import path
from .views import (
SubmitServiceAppView,
ServiceAppListView,
ServiceAppDetailView,
service_apps_config,
)
app_name = "serviceapps"
urlpatterns = [
path("submit/service-app/", SubmitServiceAppView.as_view(), name="submit"),
path("service-apps/", ServiceAppListView.as_view(), name="list"),
path("service-apps/<int:pk>/", ServiceAppDetailView.as_view(), name="detail"),
path("api/service-apps/config", service_apps_config, name="config"),
]admin.py
from django.contrib import admin
from .models import ServiceApp
@admin.register(ServiceApp)
class ServiceAppAdmin(admin.ModelAdmin):
list_display = ("display_name", "service_url", "last_status", "last_checked", "is_active")
list_filter = ("last_status", "is_active")
search_fields = ("display_name", "service_url", "author", "description")Templates
submit.html
{% extends "base.html" %}
{% block content %}
<h1>Submit a Service App</h1>
<p>Enter the base URL of your Cytoscape Web Service App.</p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Validate & Save</button>
</form>
{% endblock %}list.html
{% extends "base.html" %}
{% block content %}
<h1>Service Apps</h1>
<form method="get">
<input type="text" name="q" placeholder="Search..." value="{{ request.GET.q }}">
<select name="health">
<option value="">Any</option>
<option value="ok">OK</option>
<option value="unavailable">Unavailable</option>
<option value="error">Error</option>
</select>
<button type="submit">Filter</button>
</form>
<ul>
{% for s in services %}
<li>
<a href="{% url 'serviceapps:detail' s.id %}">{{ s.display_name|default:s.service_url }}</a>
- <em>{{ s.last_status }}</em>
</li>
{% empty %}
<li>No Service Apps yet.</li>
{% endfor %}
</ul>
{% endblock %}detail.html
{% extends "base.html" %}
{% block content %}
<h1>{{ object.display_name }}</h1>
<p>{{ object.description }}</p>
<dl>
<dt>Service URL</dt><dd><a href="{{ object.service_url }}" target="_blank">{{ object.service_url }}</a></dd>
<dt>Author</dt><dd>{{ object.author }}</dd>
<dt>Version</dt><dd>{{ object.version }}</dd>
<dt>Status</dt><dd>{{ object.last_status }}</dd>
</dl>
<h2>Parameters</h2>
<pre>{{ object.metadata.parameters|json_script:"params" }}</pre>
<h2>Actions</h2>
<pre>{{ object.metadata.cyWebActions|json_script:"actions" }}</pre>
<p><a href="/api/service-apps/config" target="_blank">View JSON Config</a></p>
{% endblock %}management/commands/check_service_apps.py
import json
import requests
from django.core.management.base import BaseCommand
from django.utils import timezone
from serviceapps.models import ServiceApp
class Command(BaseCommand):
help = "Ping /status for all active Service Apps"
def handle(self, *args, **opts):
for sa in ServiceApp.objects.filter(is_active=True):
try:
r = requests.get(sa.service_url.rstrip("/") + "/status", timeout=8)
r.raise_for_status()
data = r.json()
sa.last_status = "ok" if data.get("status") == "ok" else "unavailable"
sa.last_message = "" if sa.last_status == "ok" else json.dumps(data)[:500]
except Exception as e:
sa.last_status = "error"
sa.last_message = str(e)
sa.last_checked = timezone.now()
sa.save()
self.stdout.write(f"{sa.service_url} -> {sa.last_status}")tests.py
from django.test import TestCase
from django.urls import reverse
from unittest.mock import patch, Mock
from .models import ServiceApp
ROOT_META = {
"name": "Demo Service",
"description": "Example",
"version": "1.0.0",
"parameters": [],
"serviceInputDefinition": {},
"cyWebActions": [],
"cyWebMenuItem": {"title": "Tools/Demo"},
}
STATUS_OK = {"status": "ok"}
class SubmitServiceAppTests(TestCase):
@patch("serviceapps.views.requests.get")
def test_submit_and_config(self, mget):
def _resp(url, *args, **kwargs):
mock = Mock()
mock.raise_for_status = lambda: None
mock.json = lambda: STATUS_OK if url.endswith("/status") else ROOT_META
return mock
mget.side_effect = _resp
self.client.post(reverse("serviceapps:submit"), {"service_url": "https://example.org/demo"})
self.assertEqual(ServiceApp.objects.count(), 1)
resp = self.client.get(reverse("serviceapps:config"))
self.assertJSONEqual(resp.content, [{"url": "https://example.org/demo"}])4. API for Cytoscape Web
Endpoint:
GET /api/service-apps/config
Returns:
[
{ "url": "https://example.org/myservice" },
{ "url": "https://example.org/another" }
]This matches the AppDefinition list expected by Cytoscape Web.
5. Health Check Job
Schedule:
python manage.py check_service_appsRuns every hour/day (via cron or CI) to keep the health status fresh.
6. UI Integration
- Add “Service Apps” tab in the App Store nav.
- Show badges: Web / Service App / Status.
- Link: Try in Cytoscape Web (uses the
/api/service-apps/configendpoint).
7. Next Steps
- Integrate ServiceApp search results into main site search.
- Optional moderation (approve before visible).
- Add OpenAPI introspection if present.
- Improve detail UI with
parametersandcyWebActionstable view.
8. Testing
Run:
python manage.py test serviceappsSummary
✅ Adds serviceapps Django app
✅ Supports submission, validation, and listing
✅ Exposes Cytoscape Web config endpoint
✅ Includes management command for health
✅ Unit-tested
This implementation cleanly extends the App Store to host Cytoscape Web Service Apps alongside traditional desktop apps.