Skip to content

Add Support for Cytoscape Web Service Apps to App Store #115

@coleslaw481

Description

@coleslaw481

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 app

Add 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

  1. Submission & Validation

    • Allow authors to register Cytoscape Web Service Apps via URL.
    • Validate endpoints (/ and /status) according to the specification.
  2. Discovery & Display

    • Add a “Web (Service App)” category in the App Store UI.
    • Show metadata, health, and endpoints on each app’s detail page.
  3. API for Cytoscape Web

    • Expose a JSON endpoint returning a list of { "url": "<service_url>" } for healthy apps.
  4. Health Checks

    • Background task to periodically verify /status.

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_apps

Runs 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/config endpoint).

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 parameters and cyWebActions table view.

8. Testing

Run:

python manage.py test serviceapps

Summary

✅ 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions