diff --git a/README.md b/README.md index f5f3068..5d346e5 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,157 @@ -# OpenAPI Documentation Generator +# OpenAPI Markdown Generator -This is a Python script that generates API documentation from an OpenAPI specification file. +[![PyPI version](https://badge.fury.io/py/openapi-markdown.svg)](https://pypi.org/project/openapi-markdown/) +[![Python versions](https://img.shields.io/pypi/pyversions/openapi-markdown.svg)](https://pypi.org/project/openapi-markdown/) +[![License](https://img.shields.io/github/license/vrerv/openapi-markdown)](https://github.com/vrerv/openapi-markdown/blob/main/LICENSE) +[![Build Status](https://github.com/vrerv/openapi-markdown/actions/workflows/ci.yml/badge.svg)](https://github.com/vrerv/openapi-markdown/actions) -## Usage +A minimal tool that converts OpenAPI 3.x (JSON or YAML) into compact Markdown for documentation. -`pip install openapi-markdown` +## Features -### CLI +- Minimal, compact Markdown output optimized for small files. +- Supports OpenAPI in JSON and YAML formats. +- Jinja2 templates for full customization. +- Optional path filtering to generate docs for selected API subsets. +- CLI and library usage. +- UTF-8 reading/writing. -You can use `openapi2markdown` command as follows: +## Installation -``` -% openapi2markdown --help -Usage: openapi2markdown [OPTIONS] INPUT_FILE [OUTPUT_FILE] +Install from PyPI: - Convert OpenAPI spec to Markdown documentation. +```bash +pip install openapi-markdown +``` - INPUT_FILE: Path to OpenAPI specification file (JSON or YAML) OUTPUT_FILE: - Path where markdown file will be generated (optional, defaults to INPUT_FILE - with .md extension) +For development: -Options: - -t, --templates-dir DIRECTORY Custom templates directory path - -f, --filter-paths TEXT Only generate apis that start with the given - path, multiple paths are allowed +```bash +git clone https://github.com/vrerv/openapi-markdown.git +cd openapi-markdown +pip install -r requirements.txt +pip install -e . ``` -### Library +## Quickstart (CLI) -```python -from openapi_markdown.generator import to_markdown +Show help: -apiFile = "./tests/openapi.json" -outputFile = "api_doc.md" -templatesDir = "templates" -options = { - 'filter_paths': ['/client'] -} +```bash +openapi2markdown --help +``` -to_markdown(apiFile, outputFile, templates_dir, options) +Generate Markdown: + +```bash +openapi2markdown path/to/openapi.yaml api_doc.md ``` -- If you want to use your own template, creates 'templates' directory and put [`api_doc_template.md.j2`](src/openapi_markdown/templates/api_doc_template.md.j2) file in it. -- You can change templates directory by passing it as the 3rd argument of `to_markdown`. +Filter by path prefixes: -### Default templates +```bash +openapi2markdown spec.yaml api_doc.md --filter-paths /api/v1 --filter-paths /auth +``` -There are internal templates you can use. If not set default is used. +Use custom templates: -* templates - default -* templates/embed - prints objects hierarchially as list in every request/response +```bash +openapi2markdown spec.yaml api_doc.md --templates-dir ./my-templates +``` -## Development +## Quickstart (Python) -### Requirements +```python +from openapi_markdown.generator import to_markdown -- Python 3.x -- json -- PyYAML -- openapi-core -- Jinja2 +to_markdown( + api_file="./data/openapi.yaml", + output_file="api_doc.md", + templates_dir="templates", # optional + options={'filter_paths': ['/client']} # optional +) +``` -`pip install -r requirement.txt` +## Templates -install a project in editble mode -`pip install -e ./` +Templates live in `src/openapi_markdown/templates`. Key files: -run tests +- `api_doc_template.md.j2` — main document template +- `_content.md.j2`, `_object_schema.md.j2`, `_example.md.j2`, `_security_scheme.md.j2` — partials -`python -m unittest` +To customize, copy the `templates` folder and modify Jinja2 templates. The generator will prefer a provided directory when passed via `--templates-dir` or `templates_dir` argument. -### Deploy +## Examples +Basic conversion: + +```bash +openapi2markdown https://example.com/openapi.json api_doc.md ``` -python3 -m pip install --upgrade twine + +Only generate docs for `/users` endpoints: + +```bash +openapi2markdown openapi.yaml users.md --filter-paths /users ``` +## Development + +Requirements: + +- Python 3.8+ +- Dependencies: Jinja2, PyYAML, openapi-core (see `requirements.txt`) + +Run tests: + +```bash +python -m unittest ``` -./pypi.sh + +Coding workflow: + +```bash +git checkout -b feature/my-change +# make changes +git add . +git commit -m "Describe change" +git push origin feature/my-change +# open a pull request against upstream/main ``` + +If you don't have push access to upstream, fork first and push to your fork. + +## Contributing + +- Fork the repo, create feature branches, add tests, and open a pull request. +- Follow repository code style and include a clear PR description. +- Maintain compatibility with JSON and YAML OpenAPI specs. + +## Release + +Use the included `pypi.sh` helper or follow standard PyPI release steps with `twine`. + +## License + +MIT License - see `LICENSE` for details. + +## Contact & Support + +- Issues and feature requests: https://github.com/vrerv/openapi-markdown/issues +- For quick questions, open an issue and tag a maintainer. + +## CLI Reference + +openapi2markdown accepts the following flags: + +- `-h, --help` Show help message and exit +- `-t, --templates-dir DIR` + Use templates from DIR instead of built-in templates +- `-f, --filter-paths PREFIX` + Only include paths that start with PREFIX. Can be repeated. +- `-o, --output FILE` + Output Markdown file (default: api_doc.md) + +Example: +```bash +openapi2markdown spec.yaml docs.md --templates-dir ./my-templates --filter-paths /api/v1 \ No newline at end of file diff --git a/src/openapi_markdown/generator.py b/src/openapi_markdown/generator.py index f58cde1..6b15055 100644 --- a/src/openapi_markdown/generator.py +++ b/src/openapi_markdown/generator.py @@ -52,19 +52,17 @@ def ref_to_schema(schema, spec_data): def to_markdown(api_file, output_file, templates_dir='templates', options={}): - # Load the OpenAPI 3.0 specification file in either JSON or YAML format - with open(api_file) as f: - spec_data = json.load(f) if api_file.endswith(".json") else yaml.safe_load(f) - # Resolve all references in the spec data - # spec_data = ref_to_schema(spec_data, spec_data) - # filter spec_data.paths if filter_paths option is provided + with open(api_file, encoding='utf-8') as f: + if api_file.endswith(('.yaml', '.yml')): + spec_data = yaml.safe_load(f) + else: + spec_data = json.load(f) if 'filter_paths' in options and options['filter_paths']: spec_data['paths'] = { k: v for k, v in spec_data['paths'].items() if any(k.startswith(prefix) for prefix in options['filter_paths']) } spec = Spec.from_dict(spec_data) - # Load the Jinja2 template file if os.path.exists(templates_dir): env = Environment(loader=FileSystemLoader(templates_dir)) else: @@ -79,5 +77,5 @@ def to_markdown(api_file, output_file, templates_dir='templates', options={}): ref_to_param=lambda ref: ref_to_param(ref, spec_data), ref_to_schema=lambda ref: ref_to_schema(ref, spec_data)) ) - with open(output_file, "w") as f: - f.write(rendered_template) + with open(output_file, "w", encoding='utf-8') as f: + f.write(rendered_template) \ No newline at end of file diff --git a/src/openapi_markdown/templates/_content.md.j2 b/src/openapi_markdown/templates/_content.md.j2 index 0cd94b9..ea24b4d 100644 --- a/src/openapi_markdown/templates/_content.md.j2 +++ b/src/openapi_markdown/templates/_content.md.j2 @@ -1,18 +1,36 @@ -{% if content -%} +{% if content and content is mapping %} +{% for media_type, media in content.items() -%} +### {{ media_type }} -{% for content_type, item in content.items() -%} - -{% set schema = item.schema -%} - -{{ schema | ref_to_link }} +{% if media.schema -%} +**Schema:** +```json +{{ media.schema | tojson(indent=2) }} +``` +{% endif %} -{% if schema %} -{% include './_object_schema.md.j2' %} +{% if media.example -%} +**Example:** +```json +{{ media.example | tojson(indent=2) }} +``` {% endif %} -{% with root = item -%} -{% include './_example.md.j2' -%} -{% endwith -%} -{% endfor -%} +{% if media.examples -%} +**Examples:** +{% for ex_name, ex in media.examples.items() -%} +- **{{ ex_name }}:** {{ ex.summary or 'No summary' }} +```json +{{ ex.value | tojson(indent=2) }} +``` +{% endfor %} +{% endif %} -{% endif -%} \ No newline at end of file +{% if media.encoding -%} +**Encoding:** +{% for enc_name, enc in media.encoding.items() -%} +- **{{ enc_name }}:** {{ enc | tojson }} +{% endfor %} +{% endif %} +{% endfor %} +{% endif %} \ No newline at end of file diff --git a/src/openapi_markdown/templates/_example.md.j2 b/src/openapi_markdown/templates/_example.md.j2 index 7ffe9fd..a6f2ea0 100644 --- a/src/openapi_markdown/templates/_example.md.j2 +++ b/src/openapi_markdown/templates/_example.md.j2 @@ -1,14 +1,13 @@ -{% if root.examples %} -Examples - -{% for example_name, example in root.examples.items() %} +{% if example.summary -%} +**Summary:** {{ example.summary }} +{% endif %} -{% if example.description %} -{{ example.description }} +{% if example.description -%} +**Description:** {{ example.description }} {% endif %} +**Value:** ```json -{{ example.value | to_json }} +{{ example.value | tojson(indent=2) }} ``` -{% endfor %} -{% endif %} +{% endif %} \ No newline at end of file diff --git a/src/openapi_markdown/templates/_object_schema.md.j2 b/src/openapi_markdown/templates/_object_schema.md.j2 index 2fd535b..0e1d5f3 100644 --- a/src/openapi_markdown/templates/_object_schema.md.j2 +++ b/src/openapi_markdown/templates/_object_schema.md.j2 @@ -1,7 +1,45 @@ -{% if schema.properties -%} -| Field | Type | Description | -|-------|------|-------------| -{% for property_name, property in schema.properties.items() -%} -| {{ property_name }} | {{ property.type }} | {{ property.description if property.description else '' }} | -{% endfor -%} -{% endif -%} \ No newline at end of file +{% if schema.type == 'object' -%} +**Properties:** + +| Property | Type | Required | Description | Example | +|----------|------|----------|-------------|---------| +{% for prop_name, prop in schema.properties.items() -%} +| {{ prop_name }} | {{ prop.type or 'object' }} | {{ 'Yes' if prop_name in (schema.required or []) else 'No' }} | {{ prop.description or '' }} | {{ prop.example or '' }} | +{% endfor %} + +{% if schema.additionalProperties -%} +**Additional Properties:** Allowed +{% endif %} +{% endif %} + +{% if schema.enum -%} +**Enum Values:** {{ schema.enum | join(', ') }} +{% endif %} + +{% if schema.oneOf -%} +**One Of:** +{% for sub_schema in schema.oneOf -%} +- {{ sub_schema | tojson }} +{% endfor %} +{% endif %} + +{% if schema.allOf -%} +**All Of:** +{% for sub_schema in schema.allOf -%} +- {{ sub_schema | tojson }} +{% endfor %} +{% endif %} + +{% if schema.anyOf -%} +**Any Of:** +{% for sub_schema in schema.anyOf -%} +- {{ sub_schema | tojson }} +{% endfor %} +{% endif %} + +{% if schema.example -%} +**Example:** +```json +{{ schema.example | tojson(indent=2) }} +``` +{% endif %} \ No newline at end of file diff --git a/src/openapi_markdown/templates/_security_scheme.md.j2 b/src/openapi_markdown/templates/_security_scheme.md.j2 index 53728eb..0922129 100644 --- a/src/openapi_markdown/templates/_security_scheme.md.j2 +++ b/src/openapi_markdown/templates/_security_scheme.md.j2 @@ -1,10 +1,20 @@ +{% for scheme_name, scheme in spec.components.securitySchemes.items() -%} +### {{ scheme_name }} -{% if root.securitySchemes %} -## Security Schemes +- **Type:** {{ scheme.type }} +{% if scheme.description %}- **Description:** {{ scheme.description }}{% endif %} +{% if scheme.scheme %}- **Scheme:** {{ scheme.scheme }}{% endif %} +{% if scheme.bearerFormat %}- **Bearer Format:** {{ scheme.bearerFormat }}{% endif %} +{% if scheme.flows %}- **Flows:** +{% for flow_name, flow in scheme.flows.items() %} + - **{{ flow_name }}:** + {% if flow.authorizationUrl %}- **Authorization URL:** {{ flow.authorizationUrl }}{% endif %} + {% if flow.tokenUrl %}- **Token URL:** {{ flow.tokenUrl }}{% endif %} + {% if flow.refreshUrl %}- **Refresh URL:** {{ flow.refreshUrl }}{% endif %} + {% if flow.scopes %}- **Scopes:** {{ flow.scopes | tojson(indent=0) }}{% endif %} +{% endfor %}{% endif %} +{% if scheme.in %}- **In:** {{ scheme.in }}{% endif %} +{% if scheme.name %}- **Name:** {{ scheme.name }}{% endif %} +{% if scheme.openIdConnectUrl %}- **OpenID Connect URL:** {{ scheme.openIdConnectUrl }}{% endif %} -| Name | Type | Description | Scheme | Bearer Format | -|-------------------|-------------------|--------------------------|---------------------|---------------------------| -{% for scheme_name, scheme in root.securitySchemes.items() -%} -| {{ scheme_name }} | {{ scheme.type }} | {{ scheme.description }} | {{ scheme.scheme }} | {{ scheme.bearerFormat }} | -{% endfor %} -{% endif %} +{% endfor %} \ No newline at end of file diff --git a/src/openapi_markdown/templates/api_doc_template.md.j2 b/src/openapi_markdown/templates/api_doc_template.md.j2 index ac53cfe..8908532 100644 --- a/src/openapi_markdown/templates/api_doc_template.md.j2 +++ b/src/openapi_markdown/templates/api_doc_template.md.j2 @@ -1,89 +1,69 @@ # {{ spec.info.title }} - {{ spec.info.description }} - -# Base URL - - -| URL | Description | -|-----|-------------| -{% for server in spec.servers -%} -| {{ server.url }} | {{ server.description }} | -{% endfor %} - -{% if spec.components and spec.components.securitySchemes -%} - -# Authentication - -{% with root = spec.components -%} -{% include './_security_scheme.md.j2' -%} -{% endwith -%} -{% endif -%} - -# APIs - -{% for path, methods in spec.paths.items() -%} - -{% for method, operation in methods.items() -%} -## {{ method.upper() }} {{ path }} - -{{ operation.summary }} - -{% if operation.description and operation.description != operation.summary -%} -{{ operation.description }} -{% endif %} - -{% if operation.parameters -%} - -### Parameters - -| Name | Type | Required | Description | -|------|------|----------|-------------| -{% for param in operation.parameters -%} -{% set param_obj = ref_to_schema(param) -%} -| {{ param_obj.name }} | {{ param_obj.schema.type }} | {{ param_obj.required }} | {{ param_obj.description }} | -{% endfor -%} -{% endif %} - -{% if operation.requestBody -%} - -### Request Body - -{% set content = operation.requestBody.content -%} -{% include './_content.md.j2' %} - -{% endif -%} - -### Responses - -{% for status_code, response in operation.responses.items() -%} - -#### {{ status_code }} - -{% if response.description %} -{{ response.description }} -{% endif %} - -{% set content = response.content -%} -{% include './_content.md.j2' %} - -{% endfor -%} -{% endfor -%} -{% endfor -%} - -{% if spec.components and spec.components.schemas -%} - +**Version:** {{ spec.info.version }} +{% if spec.info.contact %}> **Contact:** {{ spec.info.contact.name }} <{{ spec.info.contact.email }}>{% endif %} +{% if spec.info.license %}> **License:** {{ spec.info.license.name }}{% endif %} +{% if spec.info.termsOfService %}> **Terms:** {{ spec.info.termsOfService }}{% endif %} +{% if spec.servers %}## Servers +| URL | Desc | +|-----|------| +{% for s in spec.servers %}{{ s.url }}|{{ s.description or '' }}| +{% endfor %}{% endif %} +{% if spec.externalDocs %}> **Docs:** {{ spec.externalDocs.url }}{% endif %} +{% if spec.security %}## Security +{% for sec in spec.security %}- {{ sec.keys()|list|first }}: {{ sec.values()|list|first|join(', ') }} +{% endfor %}{% endif %} +{% if spec.components.securitySchemes %}## Schemes +{% include './_security_scheme.md.j2' %}{% endif %} +{% if spec.tags %}## Tags +{% for t in spec.tags %}### {{ t.name }} +{{ t.description or '' }}{% endfor %}{% endif %} +# Endpoints +{% for p, m in spec.paths.items() %}## {{ p }} +{% for meth, op in m.items() %}### {{ meth|upper }} +**ID:** {{ op.operationId }}{% if op.summary %} **Sum:** {{ op.summary }}{% endif %}{% if op.description %} **Desc:** {{ op.description }}{% endif %}{% if op.deprecated %}**Deprecated**{% endif %}{% if op.tags %}**Tags:** {{ op.tags|join(', ') }}{% endif %}{% if op.externalDocs %}**Docs:** {{ op.externalDocs.url }}{% endif %}{% if op.security %}**Sec:** {% for s in op.security %}{{ s.keys()|list|first }}: {{ s.values()|list|first|join(', ') }}{% endfor %}{% endif %} +{% if op.parameters %}#### Params +| Name | In | Type | Req | Desc | Ex | +|------|----|------|-----|------|----| +{% for pr in op.parameters %}{{ pr.name }}|{{ pr.in }}|{{ pr.schema.type or 'obj' }}|{{ 'Y' if pr.required else 'N' }}|{{ pr.description or '' }}|{{ pr.example or '' }}| +{% endfor %}{% endif %} +{% if op.requestBody %}#### Body +{% include './_content.md.j2' %}{% endif %} +#### Responses +{% for sc, r in op.responses.items() %}##### {{ sc }} - {{ r.description }} +{% if r.headers %}**Headers:** {% for h in r.headers %}{{ h }}: {{ r.headers[h].schema.type or 'str' }} - {{ r.headers[h].description or '' }}{% endfor %}{% endif %} +{% include './_content.md.j2' %}{% if r.links %}**Links:** {% for l in r.links %}{{ l }}: {{ r.links[l].operationId }}{% endfor %}{% endif %}{% endfor %} +{% endfor %}{% endfor %} +{% if spec.webhooks %}# Webhooks +{% for w, wh in spec.webhooks.items() %}## {{ w }} +{% for m, o in wh.items() %}### {{ m|upper }} +**Sum:** {{ o.summary or '' }}{% if o.description %}**Desc:** {{ o.description }}{% endif %}{% if o.security %}**Sec:** {% for s in o.security %}{{ s.keys()|list|first }}: {{ s.values()|list|first|join(', ') }}{% endfor %}{% endif %}{% if o.requestBody %}#### Body +{% include './_content.md.j2' %}{% endif %}#### Responses +{% for sc, r in o.responses.items() %}##### {{ sc }} - {{ r.description }} +{% include './_content.md.j2' %}{% endfor %}{% endfor %}{% endfor %}{% endif %} # Components - -{% for schema_name, schema in spec.components.schemas.items() %} - -## {{ schema_name }} - -{% if schema.description %} -{{ schema.description }} -{% endif %} - -{% include './_object_schema.md.j2' -%} -{% endfor -%} - -{% endif -%} +{% if spec.components.schemas %}## Schemas +{% for n, s in spec.components.schemas.items() %}### {{ n }} +{% if s.title %}**Title:** {{ s.title }}{% endif %}{% if s.description %}**Desc:** {{ s.description }}{% endif %}{% if s.type == 'object' %}#### Props +| Name | Type | Req | Desc | Ex | +|------|------|-----|------|----| +{% for pn, p in s.properties.items() %}{{ pn }}|{{ p.type or 'obj' }}|{{ 'Y' if pn in (s.required or []) else 'N' }}|{{ p.description or '' }}|{{ p.example or '' }}| +{% endfor %}{% endif %}{% if s.enum %}**Enum:** {{ s.enum|join(', ') }}{% endif %}{% if s.oneOf %}**OneOf:** {% for sub in s.oneOf %}{{ sub|tojson }}{% endfor %}{% endif %}{% if s.allOf %}**AllOf:** {% for sub in s.allOf %}{{ sub|tojson }}{% endfor %}{% endif %}{% if s.anyOf %}**AnyOf:** {% for sub in s.anyOf %}{{ sub|tojson }}{% endfor %}{% endif %}{% if s.example %}**Ex:** {{ s.example|tojson(indent=0) }}{% endif %}{% endfor %}{% endif %} +{% if spec.components.parameters %}## Params +{% for n, p in spec.components.parameters.items() %}### {{ n }} +- Name: {{ p.name }}, In: {{ p.in }}, Req: {{ p.required }}, Schema: {{ p.schema|tojson }}, Desc: {{ p.description }}, Ex: {{ p.example }}{% endfor %}{% endif %} +{% if spec.components.responses %}## Responses +{% for n, r in spec.components.responses.items() %}### {{ n }} +{{ r.description }}{% if r.content %}**Content:** {% include './_content.md.j2' %}{% endif %}{% endfor %}{% endif %} +{% if spec.components.examples %}## Examples +{% for n, e in spec.components.examples.items() %}### {{ n }} +**Val:** {{ e.value|tojson(indent=0) }}{% endfor %}{% endif %} +{% if spec.components.headers %}## Headers +{% for n, h in spec.components.headers.items() %}### {{ n }} +- Desc: {{ h.description }}, Schema: {{ h.schema|tojson }}{% endfor %}{% endif %} +{% if spec.components.links %}## Links +{% for n, l in spec.components.links.items() %}### {{ n }} +- OpID: {{ l.operationId }}, Params: {{ l.parameters|tojson }}, Desc: {{ l.description }}{% endfor %}{% endif %} +{% if spec.components.callbacks %}## Callbacks +{% for n, c in spec.components.callbacks.items() %}### {{ n }} +{{ c|tojson }}{% endfor %}{% endif %} \ No newline at end of file