Skip to content

Commit

Permalink
feat: add project api
Browse files Browse the repository at this point in the history
This change massively improves the usability while doing local
development. It also fixes #40.

The local development now also proxies the `/api` and `/doc` endpoints
from the  backend (port 5000) through the frontend (port 8080)
making CORS handling simpler during development.
  • Loading branch information
randombenj committed Aug 16, 2022
1 parent ff9e61c commit cb4b9c6
Show file tree
Hide file tree
Showing 17 changed files with 189 additions and 124 deletions.
2 changes: 0 additions & 2 deletions Dockerfile
Expand Up @@ -42,9 +42,7 @@ RUN apk update && \
apk add nginx dumb-init && \
rm -rf /var/cache/apk/*

RUN mkdir -p /etc/nginx/locations.d
RUN mkdir -p /var/docat/doc
RUN chown -R nginx /var/docat /etc/nginx/locations.d

# install the application
RUN mkdir -p /var/www/html
Expand Down
36 changes: 29 additions & 7 deletions README.md
Expand Up @@ -17,7 +17,6 @@ mkdir -p docat-run/db && touch docat-run/db/db.json
docker run \
--detach \
--volume $PWD/docat-run/doc:/var/docat/doc/ \
--volume $PWD/docat-run/locations:/etc/nginx/locations.d/ \
--volume $PWD/docat-run/db/db.json:/app/docat/db.json \
--publish 8000:80 \
ghcr.io/docat-org/docat
Expand All @@ -32,7 +31,6 @@ mkdir -p docat-run/db && touch docat-run/db/db.json
docker run \
--detach \
--volume $PWD/docat-run/doc:/var/docat/doc/ \
--volume $PWD/docat-run/locations:/etc/nginx/locations.d/ \
--volume $PWD/docat-run/db:/var/docat/db/ \
--env DOCAT_DB_PATH=/var/docat/db/db.json
--publish 8000:80 \
Expand All @@ -43,10 +41,35 @@ Go to [localhost:8000](http://localhost:8000) to view your docat instance:

![docat screenshot](doc/assets/docat-screenshot.png)

If you want to run the application different than in a docker container, look at the
### Local Development

For local development, first configure and start the backend (inside the `docat/` folder):

```sh
# create a folder for local development (uploading docs)
DEV_DOC_PATH="$(mktemp -d)"

# install dependencies
poetry install

# run the local development version
DOCAT_SERVE_FILES=1 DOCAT_DOC_PATH="$DEV_DOC_PATH" poetry run python -m docat
```

After this you need to start the frontend (inside the `web/` folder):

```sh
# install dependencies
yarn install --frozen-lockfile

# run the web app
yarn serve
```

For more advanced options, have a look at the
[backend](docat/README.md) and [web](web/README.md) docs.

### Push documentation to docat
### Push Documentation to docat

The preferred way to push documentation to a docat server is using the [docatl](https://github.com/docat-org/docatl)
command line application:
Expand All @@ -57,7 +80,7 @@ docatl push --host http://localhost:8000 /path/to/your/docs PROJECT VERSION

There are also docker images available for CI systems.

#### Using standard UNIX command line tools
#### Using Standard UNIX Command Line Tools

If you have static html documentation or use something like
[mkdocs](https://www.mkdocs.org/), [sphinx](http://www.sphinx-doc.org/en/master/), ...
Expand All @@ -77,8 +100,7 @@ When you have multiple versions you may want to tag some version as **latest**:
curl -X PUT http://localhost:8000/api/PROJECT/VERSION/tags/latest
```


## Advanced config.json
## Advanced `config.json`

It is possible to configure some things after the fact.

Expand Down
56 changes: 53 additions & 3 deletions docat/docat/app.py
Expand Up @@ -20,7 +20,7 @@
from starlette.responses import JSONResponse
from tinydb import Query, TinyDB

from docat.utils import DB_PATH, UPLOAD_FOLDER, calculate_token, create_nginx_config, create_symlink, extract_archive, remove_docs
from docat.utils import DB_PATH, UPLOAD_FOLDER, calculate_token, create_symlink, extract_archive, remove_docs

#: Holds the FastAPI application
app = FastAPI(
Expand Down Expand Up @@ -56,6 +56,57 @@ class ClaimResponse(ApiResponse):
token: str


class ProjectsResponse(BaseModel):
projects: list[str]


class ProjectVersion(BaseModel):
name: str
tags: list[str]


class ProjectDetailResponse(BaseModel):
name: str
versions: list[ProjectVersion]


@app.get("/api/projects", response_model=ProjectsResponse, status_code=status.HTTP_200_OK)
def get_projects():
if not DOCAT_UPLOAD_FOLDER.exists():
return ProjectsResponse(projects=[])
return ProjectsResponse(projects=[str(x.relative_to(DOCAT_UPLOAD_FOLDER)) for x in DOCAT_UPLOAD_FOLDER.iterdir() if x.is_dir()])


@app.get(
"/api/projects/{project}",
response_model=ProjectDetailResponse,
status_code=status.HTTP_200_OK,
responses={status.HTTP_404_NOT_FOUND: {"model": ApiResponse}},
)
def get_project(project):
docs_folder = DOCAT_UPLOAD_FOLDER / project
if not docs_folder.exists():
return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"message": f"Project {project} does not exist"})

tags = [x for x in docs_folder.iterdir() if x.is_dir() and x.is_symlink()]

return ProjectDetailResponse(
name=project,
versions=sorted(
[
ProjectVersion(
name=str(x.relative_to(docs_folder)),
tags=[str(t.relative_to(docs_folder)) for t in tags if t.resolve() == x],
)
for x in docs_folder.iterdir()
if x.is_dir() and not x.is_symlink()
],
key=lambda k: k.name,
reverse=True,
),
)


@app.post("/api/{project}/{version}", response_model=ApiResponse, status_code=status.HTTP_201_CREATED)
def upload(
project: str,
Expand Down Expand Up @@ -86,7 +137,6 @@ def upload(
shutil.copyfileobj(file.file, buffer)

extract_archive(target_file, base_path)
create_nginx_config(project, project_base_path)
return ApiResponse(message="File successfully uploaded")


Expand Down Expand Up @@ -154,4 +204,4 @@ def check_token_for_project(db, token, project) -> TokenStatus:

# serve_local_docs for local testing without a nginx
if os.environ.get("DOCAT_SERVE_FILES"):
app.mount("/doc", StaticFiles(directory=DOCAT_UPLOAD_FOLDER), name="docs")
app.mount("/doc", StaticFiles(directory=DOCAT_UPLOAD_FOLDER, html=True), name="docs")
2 changes: 0 additions & 2 deletions docat/docat/nginx/default
Expand Up @@ -15,8 +15,6 @@ server {

location /doc {
root /var/docat;
autoindex on;
autoindex_format json;
}

location /api {
Expand Down
5 changes: 0 additions & 5 deletions docat/docat/templates/nginx-doc.conf

This file was deleted.

23 changes: 0 additions & 23 deletions docat/docat/utils.py
Expand Up @@ -4,12 +4,9 @@
import hashlib
import os
import shutil
import subprocess
from pathlib import Path
from zipfile import ZipFile

from jinja2 import Template

NGINX_CONFIG_PATH = Path("/etc/nginx/locations.d")
UPLOAD_FOLDER = Path("/var/docat/doc")
DB_PATH = "db.json"
Expand All @@ -33,26 +30,6 @@ def create_symlink(source, destination):
return False


def create_nginx_config(project, project_base_path):
"""
Creates an Nginx configuration for an uploaded project
version.
Args:
project (str): name of the project
project_base_path (pathlib.Path): base path of the project
"""
nginx_config = NGINX_CONFIG_PATH / f"{project}-doc.conf"
if not nginx_config.exists():
out_parsed_template = Template((Path(__file__).parent / "templates" / "nginx-doc.conf").read_text()).render(
project=project, dir_path=str(project_base_path)
)
with nginx_config.open("w") as f:
f.write(out_parsed_template)

subprocess.run(["nginx", "-s", "reload"])


def extract_archive(target_file, destination):
"""
Extracts the given archive to the directory
Expand Down
5 changes: 1 addition & 4 deletions docat/tests/conftest.py
Expand Up @@ -31,19 +31,16 @@ def client_with_claimed_project(client):
@pytest.fixture
def temp_project_version(tmp_path):
docs = tmp_path / "doc"
config = tmp_path / "location.d"

docs.mkdir()
config.mkdir()

def __create(project, version):
(config / f"{project}-doc.conf").touch()
version_docs = docs / project / version
version_docs.mkdir(parents=True)
(version_docs / "index.html").touch()

create_symlink(version_docs, docs / project / "latest")

return docs, config
return docs

yield __create
45 changes: 45 additions & 0 deletions docat/tests/test_project.py
@@ -0,0 +1,45 @@
from unittest.mock import patch

from fastapi.testclient import TestClient

from docat.app import app

client = TestClient(app)


def test_project_api(temp_project_version):
project = "project"
docs = temp_project_version(project, "1.0")

with patch("docat.app.DOCAT_UPLOAD_FOLDER", docs):
response = client.get("/api/projects")

assert response.ok
assert response.json() == {"projects": ["project"]}


def test_project_api_without_any_projects():
response = client.get("/api/projects")

assert response.ok
assert response.json() == {"projects": []}


def test_project_details_api(temp_project_version):
project = "project"
docs = temp_project_version(project, "1.0")
symlink_to_latest = docs / project / "latest"
assert symlink_to_latest.is_symlink()

with patch("docat.app.DOCAT_UPLOAD_FOLDER", docs):
response = client.get(f"/api/projects/{project}")

assert response.ok
assert response.json() == {"name": "project", "versions": [{"name": "1.0", "tags": ["latest"]}]}


def test_project_details_api_with_a_project_that_does_not_exist():
response = client.get("/api/projects/i-do-not-exist")

assert not response.ok
assert response.json() == {"message": "Project i-do-not-exist does not exist"}
8 changes: 4 additions & 4 deletions docat/tests/test_upload.py
Expand Up @@ -3,7 +3,7 @@


def test_successfully_upload(client):
with patch("docat.app.remove_docs"), patch("docat.app.create_nginx_config"):
with patch("docat.app.remove_docs"):
response = client.post("/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")})
response_data = response.json()

Expand All @@ -12,7 +12,7 @@ def test_successfully_upload(client):


def test_successfully_override(client_with_claimed_project):
with patch("docat.app.remove_docs") as remove_mock, patch("docat.app.create_nginx_config"):
with patch("docat.app.remove_docs") as remove_mock:
response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
)
Expand All @@ -31,7 +31,7 @@ def test_successfully_override(client_with_claimed_project):


def test_tags_are_not_overwritten_without_api_key(client_with_claimed_project):
with patch("docat.app.remove_docs") as remove_mock, patch("docat.app.create_nginx_config"):
with patch("docat.app.remove_docs") as remove_mock:
response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
)
Expand All @@ -51,7 +51,7 @@ def test_tags_are_not_overwritten_without_api_key(client_with_claimed_project):


def test_fails_with_invalid_token(client_with_claimed_project):
with patch("docat.app.remove_docs") as remove_mock, patch("docat.app.create_nginx_config"):
with patch("docat.app.remove_docs") as remove_mock:
response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")}
)
Expand Down
46 changes: 6 additions & 40 deletions docat/tests/test_utils.py
@@ -1,7 +1,7 @@
from pathlib import Path
from unittest.mock import MagicMock, mock_open, patch
from unittest.mock import MagicMock, patch

from docat.utils import create_nginx_config, create_symlink, extract_archive, remove_docs
from docat.utils import create_symlink, extract_archive, remove_docs


def test_symlink_creation():
Expand Down Expand Up @@ -54,38 +54,6 @@ def test_symlink_creation_do_not_overwrite_destination():
destination.symlink_to.assert_not_called()


def test_create_nginx_config():
"""
Tests the creation of the nginx config
"""
jinja_template_mock = "{{ project }}:{{ dir_path }}"
with patch.object(Path, "read_text", return_value=jinja_template_mock), patch.object(Path, "exists") as mock_exists, patch.object(
Path, "open", mock_open()
) as mock_config, patch("subprocess.run"):
mock_exists.return_value = False

create_nginx_config("awesome-project", "/some/dir")

mock_config.assert_called_once_with("w")
mock_config().write.assert_called_once_with("awesome-project:/some/dir")


def test_not_overwrite_nginx_config():
"""
Tests wether the nginx config does not get
overwritten
"""
jinja_template_mock = "{{ project }}:{{ dir_path }}"
with patch("builtins.open", mock_open(read_data=jinja_template_mock)), patch.object(Path, "exists") as mock_exists, patch.object(
Path, "open", mock_open()
) as mock_config, patch("subprocess.run"):
mock_exists.return_value = True

create_nginx_config("awesome-project", "/some/dir")

assert not mock_config.called


def test_archive_artifact():
target_file = Path("/some/zipfile.zip")
destination = "/tmp/null"
Expand All @@ -101,23 +69,21 @@ def test_archive_artifact():


def test_remove_version(temp_project_version):
docs, config = temp_project_version("project", "1.0")
with patch("docat.utils.UPLOAD_FOLDER", docs), patch("docat.utils.NGINX_CONFIG_PATH", config):
docs = temp_project_version("project", "1.0")
with patch("docat.utils.UPLOAD_FOLDER", docs):
remove_docs("project", "1.0")

assert docs.exists()
assert not (docs / "project").exists()
assert config.exists()
assert not (config / "project-doc.conf").exists()


def test_remove_symlink_version(temp_project_version):
project = "project"
docs, config = temp_project_version(project, "1.0")
docs = temp_project_version(project, "1.0")
symlink_to_latest = docs / project / "latest"
assert symlink_to_latest.is_symlink()

with patch("docat.utils.UPLOAD_FOLDER", docs), patch("docat.utils.NGINX_CONFIG_PATH", config):
with patch("docat.utils.UPLOAD_FOLDER", docs):
remove_docs(project, "latest")

assert not symlink_to_latest.exists()

0 comments on commit cb4b9c6

Please sign in to comment.