From f9be6e53a268d8e52006375f6c9e7cfe7234c9dd Mon Sep 17 00:00:00 2001 From: Marc Wrobel Date: Fri, 16 Dec 2022 22:34:24 +0100 Subject: [PATCH 1/3] First version --- ...reate-json-files.rb => generate-api-v0.rb} | 0 _plugins/generate-api-v1.rb | 89 +++++++++++++++++++ 2 files changed, 89 insertions(+) rename _plugins/{create-json-files.rb => generate-api-v0.rb} (100%) create mode 100755 _plugins/generate-api-v1.rb diff --git a/_plugins/create-json-files.rb b/_plugins/generate-api-v0.rb similarity index 100% rename from _plugins/create-json-files.rb rename to _plugins/generate-api-v0.rb diff --git a/_plugins/generate-api-v1.rb b/_plugins/generate-api-v1.rb new file mode 100755 index 00000000000..2f93923af10 --- /dev/null +++ b/_plugins/generate-api-v1.rb @@ -0,0 +1,89 @@ +#!/usr/bin/env ruby + +# This script creates API files for version 1 of the endoflife.date API. +# +# There are three kind of generated files : +# - all.json: contains the list of all the product names. +# - .json: contains a given product data ()including releases data). +# - /.json: contains a given product release data. + +require 'fileutils' +require 'json' +require 'yaml' +require 'date' + +module ApiV1 + + # This API path + DIR = 'api/v1' + + # Returns the path of a file inside the API namespace. + def self.file(name, *args) + File.join(DIR, name, *args) + end + + # Holds information about a product. + Product = Class.new do + attr_accessor :data + + # Initializes the product with the given product's markdown file. + # The markdown file is expected to contain a YAML front-matter with the appropriate properties. + # + # Copying the data makes it easier to process it. + def initialize(data) + @data = Hash.new + # The product name is derived from the product's permalink (ex. /debian => debian). + @data["name"] = data['permalink'][1..data['permalink'].length] + @data["title"] = data['title'] + @data["category"] = data['category'] + @data["iconSlug"] = data['iconSlug'] + @data["permalink"] = data['permalink'] + @data["versionCommand"] = data['versionCommand'] + @data["auto"] = data.has_key? 'auto' + @data["releasePolicyLink"] = data['releasePolicyLink'] + @data["releases"] = data['releases'].map do |release| + release_data = Hash.new + release_data["name"] = release['releaseCycle'] + release_data["codename"] = release['codename'] + release_data["releaseDate"] = release['releaseDate'] + release_data["support"] = release['support'] + release_data["eol"] = release['eol'] + release_data["discontinued"] = release['discontinued'] + release_data["lts"] = release['lts'] || false # lts is optional, make sure it always has a value + release_data["latest"] = release['latest'] + release_data["latestReleaseDate"] = release['latestReleaseDate'] + release_data + end + end + + def name + data["name"] + end + end +end + +product_names = [] +FileUtils.mkdir_p(ApiV1::file('.')) + +Dir['products/*.md'].each do |file| + # Load and prepare data + raw_data = YAML.safe_load(File.open(file), permitted_classes: [Date]) + product = ApiV1::Product.new(raw_data) + product_names.append(product.name) + + # Write /.json + product_file = ApiV1::file("#{product.name}.json") + File.open(product_file, 'w') { |f| f.puts product.data.to_json } + + # Write all //.json + FileUtils.mkdir_p(ApiV1::file(product.name)) + product.data["releases"].each do |release| + # Any / characters in the name are replaced with - to avoid file errors. + release_file = ApiV1::file(product.name, "#{release['name'].to_s.tr('/', '-')}.json") + File.open(release_file, 'w') { |f| f.puts release.to_json } + end +end + +# Write /all.json +all_products_file = ApiV1::file('all.json') +File.open(all_products_file, 'w') { |f| f.puts product_names.sort.to_json } From 9638a2fd0732dddb76857d7a26630d33cf0192d2 Mon Sep 17 00:00:00 2001 From: Marc Wrobel Date: Sat, 17 Dec 2022 13:23:05 +0100 Subject: [PATCH 2/3] Add API v1 (#2595, #2425, #2331, #2066, #2062, #1762, #759, #394, #2530) This is a major rework of the API with a lot of breaking changes. See CHANGELOG_API.md for more information. Note that we thought of disabling API generation in development (using JEKYLL_ENV like the Jekyll Feed plugin - see https://github.com/jekyll/jekyll-feed/blob/master/lib/jekyll-feed/generator.rb#L145), but it was finally reverted. It does not work well with Netlify preview, and generate production URL (i.e. https://endoflife.date URLs) in development which makes it difficult to use. --- CHANGELOG_API.md | 96 ++++++ HACKING.md | 8 +- README.md | 7 +- _config.yml | 2 +- _headers | 4 + _layouts/json.json | 1 + _layouts/product.html | 4 +- _layouts/swagger-ui.html | 31 ++ _plugins/generate-api-v1.rb | 342 +++++++++++++++---- _plugins/product-data-enricher.rb | 23 +- _redirects | 22 +- api_v1/openapi.yml | 535 ++++++++++++++++++++++++++++++ api_v1/swagger-ui.md | 6 + assets/404.json | 3 - humans.txt | 2 +- index.md | 4 +- 16 files changed, 996 insertions(+), 94 deletions(-) create mode 100644 CHANGELOG_API.md create mode 100644 _layouts/json.json create mode 100644 _layouts/swagger-ui.html create mode 100644 api_v1/openapi.yml create mode 100644 api_v1/swagger-ui.md delete mode 100644 assets/404.json diff --git a/CHANGELOG_API.md b/CHANGELOG_API.md new file mode 100644 index 00000000000..3bc9e66b4bd --- /dev/null +++ b/CHANGELOG_API.md @@ -0,0 +1,96 @@ +## API v1.0.0 + +### Summary + +API v1 is a major rework of the API v0 with a lot of breaking changes. Compared to the API v0, API +v1: + +- feels more _Restful_ (#2431), +- expose almost all product's data (#394, #759, #2062, #2595), +- expose new metadata such as `schema version` (#2331), `total` (for lists), `generated_at` or + `last modified` date, +- is easier to consume thanks to: + - new computed fields such as `is_maintained`, + - the replacement of fields that were using union types with two separate single-type fields: + - `lts` -> `isLts` and `ltsFrom`, + - `support` -> `isActiveSupportOver` and `activeSupportUntil`, + - `eol` -> `isEol` and `eolFrom`, + - `discontinued` -> `isDiscontinued` and `discontinuedFrom`, + - `extendedSupport` -> `isExtendedSupportOver` and `extendedSupportUntil`. +- provide new endpoints (#2078, #2160, #2530) +- is versioned using the `api/v1` prefix (#2066), making it easier to implement + non-backward-compatible changes in the future, +- is documented using [swagger-ui](https://swagger.io/tools/swagger-ui/) instead of [Stoplight + Elements WebComponent](https://github.com/stoplightio/elements/blob/main/docs/getting-started/elements/html.md) + (#905), +- but reverts #2425 due to incompatibilities in redirect rules. + +The API v1 is now generated using a Jekyll Generator (see https://jekyllrb.com/docs/plugins/generators/) +instead of a custom script. + +Note that the API v0 is still generated to give time to users to migrate to API v1. It will be +decommissioned at least one year after the API v1 release date. + +API v1 documentation can be seen on . +The old API v0 documentation can still be seen on . + +### Changes in the "All products" endpoint + +- Path has been changed from `api/all.json` to `api/v1/products/` +- Response has been changed from a simple array of strings to a JSON document. + This made it possible to include additional metadata, such as the schema version and the number of + products. +- Response items has been changed from a simple string (the product name) to a JSON document (#2062). + This made it possible to include additional information about the product, such as its category + and tags. +- See for a detailed description of the + response. + +### Changes in the "Product" endpoint + +- Path has been changed from `api/.json` to `api/v1/products//`. +- Response has been changed from a simple array of versions to a JSON document. + This made it possible to include : + - additional metadata, such as the schema version and the last modified date, + - product-level information, such as the product label or category (#2062). +- Cycles data now always contain all the release cycles properties, even if they are null + (example: `discontinued`, `latest`, `latestReleaseDate`, `support`...). +- See for a detailed + description of the response. + +### Changes in the "Cycle" endpoint + +- Path has been changed from `api//.json` to `api/v1/products//cycles//`. +- Response has been changed to make it possible to include additional metadata, such as the schema + version and the last modified date, +- Cycles data now always contain all the release cycles properties, even if they are null + (example: `discontinued`, `latest`, `latestReleaseDate`, `support`...). +- A special `/api/v1/products//cycles/latest/` cycle, containing the same data as the + latest cycle, has been added (#2078). +- See for a + detailed description of the response. + +### Changes in 404 error responses + +404 error JSON responses are not returned anymore. #2425 has been reverted because it conflicted +with the rule that rewrites the paths to add `/index.json` to all requests, which is also a global +rule and [takes precedence](https://docs.netlify.com/routing/redirects/#rule-processing-order). + +### New endpoints + +- `/api/v1/categories/`: Get a list of all categories. +- `/api/v1/categories/`: Get a list of all products within the given category. +- `/api/v1/tags/`: Get a list of all tags. +- `/api/v1/tags/`: Get a list of all products having the given tag. +- `/api/v1/products/full/`: Get a list of all products with all their details (including cycles). + This endpoint provides a dump of nearly all the endoflife.date data. + + + +## API v0 + +On 2023-03-02 the v0 endpoints were: + +- "All products" (`/api/all.json`) : Get a list of all product names. +- "Product" (`/api/{product}.json`) : Get all release cycles details for a given product. +- "Cycle" (`/api/{product}/{cycle}.json`) : Get details for a single release cycle of a given product. diff --git a/HACKING.md b/HACKING.md index cc1821b3b16..cc8cb0394b2 100644 --- a/HACKING.md +++ b/HACKING.md @@ -103,7 +103,13 @@ The API is just JSON files generated in the `api` directory by `_plugins/create- ### API Documentation -The API Documentation is available at and is generated from an OpenAPI Specification file located at `_data/openapi.yml`. The documentation is rendered [Stoplight Elements](https://meta.stoplight.io/docs/elements/ZG9jOjMyNjU4OTY0-introduction-to-elements). +The current API v1 documentation is available at and is +generated from an OpenAPI Specification file located at `api_v1/openapi.yml`. The documentation is +rendered by [Swagger UI](https://swagger.io/tools/swagger-ui/). + +The old API v0 documentation is available at and is +generated from an OpenAPI Specification file located at `assets/openapi.yml`. The documentation is +rendered by [Stoplight Elements](https://meta.stoplight.io/docs/elements/ZG9jOjMyNjU4OTY0-introduction-to-elements). ## Contributing Workflow diff --git a/README.md b/README.md index b0cc43798b9..e0036321b99 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,8 @@ While participating in the project, you must abide by its [Code of Conduct](CODE ## API -An API is available for integration with CI platforms. -API documentation is available at https://endoflife.date/docs/api. -The API is currently in Alpha, and breaking changes can happen. +An API is available for integration with CI platforms. API documentation is available at https://endoflife.date/docs/api/v1/. +The API is currently in Beta, and breaking changes can happen. ## License @@ -46,6 +45,8 @@ endoflife.date is relying on various amazing software and components : - [Just the Docs](https://github.com/just-the-docs/just-the-docs), a documentation theme for Jekyll. - [Stoplight Elements](https://stoplight.io/open-source/elements), a collection of UI components for displaying beautiful developer documentation from any OpenAPI document. +- [Swagger UI](https://swagger.io/tools/swagger-ui/), a documentation generator for OpenAPI + Specification. - [Simple Icons](https://simpleicons.org/), free SVG icons for popular brands. - Our icon is derived from [Hourglass icon (orange)](https://commons.wikimedia.org/wiki/File:Hourglass_icon_%28orange%29.svg) by David Abián and Serhio Magpie on the English Wikipedia. Remixed under the CC-BY-SA-4.0 license. diff --git a/_config.yml b/_config.yml index 708b02b4dd7..de8f4cf8ade 100644 --- a/_config.yml +++ b/_config.yml @@ -40,7 +40,7 @@ aux_links: Source: - https://github.com/endoflife-date/endoflife.date API: - - /docs/api + - /docs/api/v1/ "Release Data": - https://github.com/endoflife-date/release-data/ diff --git a/_headers b/_headers index 7bb1e154e5e..1c17bb4ade4 100644 --- a/_headers +++ b/_headers @@ -56,6 +56,10 @@ layout: null Content-Security-Policy: default-src 'none'; manifest-src 'self'; connect-src 'self'; script-src 'self'; style-src 'self'; img-src {{ defaultCspImgSrc }} {{ releaseImageSrc }} Link: /api{{page.permalink}}.json; rel=alternate;type=application/json Link: /calendar{{page.permalink}}.ics; rel=alternate;type=text/calendar + {% elsif page.permalink contains '/docs/api/v' %} + {%- comment %}Used contains to match all API version (startswith does not exist){% endcomment %} + # unsafe-inline and data: should not be an issue for a static site + Content-Security-Policy: default-src 'none'; manifest-src 'self'; connect-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com/; style-src 'self' https://unpkg.com/; img-src 'self' data: {% elsif page.permalink == '/docs/api' %} Content-Security-Policy: default-src 'none'; manifest-src 'self'; connect-src 'self'; script-src 'self' https://unpkg.com/@stoplight/elements/web-components.min.js; style-src 'self' https://unpkg.com/@stoplight/elements/ 'unsafe-inline' {% else %} diff --git a/_layouts/json.json b/_layouts/json.json new file mode 100644 index 00000000000..8c98299c885 --- /dev/null +++ b/_layouts/json.json @@ -0,0 +1 @@ +{{ page.data | jsonify }} diff --git a/_layouts/product.html b/_layouts/product.html index 3a8bffcab08..674e2d664c0 100644 --- a/_layouts/product.html +++ b/_layouts/product.html @@ -197,7 +197,7 @@

{{ page.title }}

- A JSON version of this page is available at /api{{page.permalink}}.json. - See the API Documentation for more information. + A JSON version of this page is available at /api/v1/products{{page.permalink}}/. + See the API Documentation for more information. You can subscribe to the iCalendar feed at /calendar{{page.permalink}}.ics.

diff --git a/_layouts/swagger-ui.html b/_layouts/swagger-ui.html new file mode 100644 index 00000000000..b75f0d38846 --- /dev/null +++ b/_layouts/swagger-ui.html @@ -0,0 +1,31 @@ +--- +layout: null +--- + + + + + {{ page.title }} + + + + + +
+ + + + + + diff --git a/_plugins/generate-api-v1.rb b/_plugins/generate-api-v1.rb index 2f93923af10..f979c5b44cd 100755 --- a/_plugins/generate-api-v1.rb +++ b/_plugins/generate-api-v1.rb @@ -1,89 +1,287 @@ -#!/usr/bin/env ruby - # This script creates API files for version 1 of the endoflife.date API. # -# There are three kind of generated files : -# - all.json: contains the list of all the product names. -# - .json: contains a given product data ()including releases data). -# - /.json: contains a given product release data. +# There are multiples endpoints : +# +# - /api/v1/ - list all major endpoints (those not requiring a parameter) +# - /api/v1/products/ - list all products +# - /api/v1/products// - get a single product details +# - /api/v1/products//latest - get details on the latest cycle for the given product +# - /api/v1/products// - get details on the given cycle for the given product +# - /api/v1/categories/ - list categories used on endoflife.date +# - /api/v1/categories/ - list products having the given category +# - /api/v1/tags/ - list tags used on endoflife.date +# - /api/v1/tags/ - list products having the given tag + -require 'fileutils' -require 'json' -require 'yaml' -require 'date' +require 'jekyll' module ApiV1 - # This API path - DIR = 'api/v1' + VERSION = '1.0.0' + MAJOR_VERSION = VERSION.split('.')[0] + + STRIP_HTML_BLOCKS = Regexp.union( + //m, + //m, + //m + ) + STRIP_HTML_TAGS = /<.*?>/m + + # Remove HTML from a string (such as an LTS label). + # This is the equivalent of Liquid::StandardFilters.strip_html, which cannot be used + # unfortunately. + def self.strip_html(input) + empty = ''.freeze + result = input.to_s.gsub(STRIP_HTML_BLOCKS, empty) + result.gsub!(STRIP_HTML_TAGS, empty) + result + end + + def self.site_url(site, path) + "#{site.config['url']}#{path}" + end - # Returns the path of a file inside the API namespace. - def self.file(name, *args) - File.join(DIR, name, *args) + def self.api_url(site, path) + site_url(site, "/api/v#{ApiV1::MAJOR_VERSION}#{path}") end - # Holds information about a product. - Product = Class.new do - attr_accessor :data - - # Initializes the product with the given product's markdown file. - # The markdown file is expected to contain a YAML front-matter with the appropriate properties. - # - # Copying the data makes it easier to process it. - def initialize(data) - @data = Hash.new - # The product name is derived from the product's permalink (ex. /debian => debian). - @data["name"] = data['permalink'][1..data['permalink'].length] - @data["title"] = data['title'] - @data["category"] = data['category'] - @data["iconSlug"] = data['iconSlug'] - @data["permalink"] = data['permalink'] - @data["versionCommand"] = data['versionCommand'] - @data["auto"] = data.has_key? 'auto' - @data["releasePolicyLink"] = data['releasePolicyLink'] - @data["releases"] = data['releases'].map do |release| - release_data = Hash.new - release_data["name"] = release['releaseCycle'] - release_data["codename"] = release['codename'] - release_data["releaseDate"] = release['releaseDate'] - release_data["support"] = release['support'] - release_data["eol"] = release['eol'] - release_data["discontinued"] = release['discontinued'] - release_data["lts"] = release['lts'] || false # lts is optional, make sure it always has a value - release_data["latest"] = release['latest'] - release_data["latestReleaseDate"] = release['latestReleaseDate'] - release_data + class ApiGenerator < Jekyll::Generator + safe true + priority :lowest + + TOPIC = "API " + ApiV1::VERSION + ":" + + def generate(site) + @site = site + start = Time.now + Jekyll.logger.info TOPIC, "Generating..." + + product_pages = site.pages.select { |page| page.data['layout'] == 'product' } + add_index_page(site) + add_products_related_pages(site, product_pages) + add_categories_related_pages(site, product_pages) + add_tags_related_pages(site, product_pages) + + Jekyll.logger.info TOPIC, "Done in #{(Time.now - start).round(3)} seconds." + end + + private + + def add_index_page(site) + site.pages << JsonPage.of_raw_data(site, '/', [ + { name: "products", uri: "#{ApiV1.api_url(site, '/products/')}" }, + { name: "categories", uri: "#{ApiV1.api_url(site, '/categories/')}" }, + { name: "tags", uri: "#{ApiV1.api_url(site, '/tags/')}" }, + ]) + end + + def add_products_related_pages(site, products) + add_all_products_page(site, products) + add_all_products_and_cycles_page(site, products) + + products.each do |page| + add_product_page(site, page) + add_latest_cycle_page(site, page) + page.data['releases'].each { |cycle| add_cycle_page(site, page, cycle) } + end + end + + def add_all_products_page(site, products) + site.pages << JsonPage.of_products_summary(site, '/products/', products) + end + + def add_all_products_and_cycles_page(site, products) + site.pages << JsonPage.of_products_details(site, '/products/full/', products) + end + + def add_product_page(site, product) + site.pages << JsonPage.of_product(site, product) + end + + def add_latest_cycle_page(site, page) + latest = page.data['releases'][0] + site.pages << JsonPage.of_cycle(site, page, latest, 'latest') + end + + def add_cycle_page(site, page, cycle) + site.pages << JsonPage.of_cycle(site, page, cycle) + end + + def add_categories_related_pages(site, products) + products_by_category = products_by_category(products) + + add_all_categories_page(site, products_by_category.keys) + products_by_category.each do |category, products| + add_category_page(site, category, products) + end + end + + def products_by_category(products) + products_by_category = {} + products.each { |product| add_to_map(products_by_category, product.data['category'], product) } + products_by_category + end + + def add_category_page(site, category, products) + site.pages << JsonPage.of_products_summary(site, "/categories/#{category}", products) + end + + def add_all_categories_page(site, categories) + data = categories.map { |category| { name: category, uri: "#{ApiV1.api_url(site, "/categories/#{category}/")}" }} + meta = { total: categories.size() } + site.pages << JsonPage.of_raw_data(site, '/categories/', data, meta) + end + + def add_tags_related_pages(site, products) + products_by_tag = products_by_tag(products) + + add_all_tags_page(site, products_by_tag.keys) + products_by_tag.each do |tag, products| + add_tag_page(site, tag, products) end end - def name - data["name"] + def products_by_tag(products) + products_by_tag = {} + products.each do |product| + product.data['tags'].each { |tag| add_to_map(products_by_tag, tag, product) } + end + products_by_tag + end + + def add_tag_page(site, tag, products) + site.pages << JsonPage.of_products_summary(site, "/tags/#{tag}", products) + end + + def add_all_tags_page(site, tags) + data = tags.map { |tag| { name: tag, uri: "#{ApiV1.api_url(site, "/tags/#{tag}/")}" }} + meta = { total: tags.size() } + site.pages << JsonPage.of_raw_data(site, '/tags/', data, meta) + end + + def add_to_map(map, key, page) + if map.has_key? key + map[key] << page + else + map[key] = [page] + end end end -end -product_names = [] -FileUtils.mkdir_p(ApiV1::file('.')) - -Dir['products/*.md'].each do |file| - # Load and prepare data - raw_data = YAML.safe_load(File.open(file), permitted_classes: [Date]) - product = ApiV1::Product.new(raw_data) - product_names.append(product.name) - - # Write /.json - product_file = ApiV1::file("#{product.name}.json") - File.open(product_file, 'w') { |f| f.puts product.data.to_json } - - # Write all //.json - FileUtils.mkdir_p(ApiV1::file(product.name)) - product.data["releases"].each do |release| - # Any / characters in the name are replaced with - to avoid file errors. - release_file = ApiV1::file(product.name, "#{release['name'].to_s.tr('/', '-')}.json") - File.open(release_file, 'w') { |f| f.puts release.to_json } + class JsonPage < Jekyll::Page + class << self + private :new + + def of_raw_data(site, path, data, metadata = {}) + new(site, path, data, metadata) + end + + def of_products_summary(site, path, products) + data = products.map { |product| product_summary_to_json(site, product) } + meta = { total: products.size() } + new(site, path, data, meta) + end + + def of_products_details(site, path, products) + data = products.map { |product| product_to_json(site, product) } + meta = { total: products.size() } + new(site, path, data, meta) + end + + def of_product(site, product) + path = "/products/#{product.data['id']}" + data = product_to_json(site, product) + meta = { + # https://github.com/gjtorikian/jekyll-last-modified-at/blob/master/lib/jekyll-last-modified-at/determinator.rb + last_modified: product.data['last_modified_at'].last_modified_at_time.iso8601, + auto: product.data.has_key?('auto'), + } + new(site, path, data, meta) + end + + def of_cycle(site, product, cycle, identifier = nil) + name = identifier ? identifier : cycle['id'] + path = "/products/#{product.data['id']}/cycles/#{name}" + data = cycle_to_json(cycle) + new(site, path, data, {}) + end + + def product_to_json(site, product) + additional_details = { + versionCommand: product.data['versionCommand'], + identifiers: product.data['identifiers'].map { |identifier| { + type: identifier.keys.first, + id: identifier.values.first + } }, + labels: { + "activeSupport": product.data['activeSupportColumn'] ? ApiV1.strip_html(product.data['activeSupportColumnLabel']) : nil, + "discontinued": product.data['discontinuedColumn'] ? ApiV1.strip_html(product.data['discontinuedColumnLabel']) : nil, + "eol": product.data['eolColumn'] ? ApiV1.strip_html(product.data['eolColumnLabel']) : nil, + "extendedSupport": product.data['extendedSupportColumn'] ? ApiV1.strip_html(product.data['extendedSupportColumnLabel']) : nil, + }, + links: { + icon: product.data['iconUrl'], + html: ApiV1.site_url(site, "/#{product.data['id']}"), + releasePolicy: product.data['releasePolicyLink'], + }, + cycles: product.data['releases'].map { |cycle| cycle_to_json(cycle) } + } + + product_summary_to_json(site, product).except(:uri).merge(additional_details) + end + + def product_summary_to_json(site, product) + { + name: product.data['id'], + aliases: product.data['aliases'], + label: product.data['title'], + category: product.data['category'], + tags: product.data['tags'], + uri: ApiV1.api_url(site, "/products/#{product.data['id']}/") + } + end + + def cycle_to_json(cycle) + { + name: cycle['releaseCycle'], + codename: cycle['codename'], + label: ApiV1.strip_html(cycle['label']), + date: cycle['releaseDate'], + isLts: cycle['is_lts'], + ltsFrom: cycle['lts_from'], + isActiveSupportOver: cycle['is_active_support_over'], + activeSupportUntil: cycle['active_support_until'], + isEol: cycle['is_eol'], + eolFrom: cycle['eol_from'], + isDiscontinued: cycle['is_discontinued'], + discontinuedFrom: cycle['discontinued_from'], + isExtendedSupportOver: cycle['is_extended_support_over'], + extendedSupportUntil: cycle['extended_support_until'], + isMaintained: cycle['is_maintained'], + latest: { + name: cycle['latest'], + date: cycle['latestReleaseDate'], + link: cycle['link'], + } + } + end + end + + def initialize(site, path, data, metadata) + @site = site + @base = site.source + @dir = "api/v#{ApiV1::MAJOR_VERSION}#{path}" + @name = "index.json" + @data = {} + @data['layout'] = 'json' + + @data['data'] = {} + @data['data']['schema_version'] = ApiV1::VERSION + @data['data']['generated_at'] = site.time.iso8601 + @data['data'].merge!(metadata) + @data['data']['result'] = data + + self.process(@name) + end end end - -# Write /all.json -all_products_file = ApiV1::file('all.json') -File.open(all_products_file, 'w') { |f| f.puts product_names.sort.to_json } diff --git a/_plugins/product-data-enricher.rb b/_plugins/product-data-enricher.rb index 4919d64d04f..d2672d32178 100644 --- a/_plugins/product-data-enricher.rb +++ b/_plugins/product-data-enricher.rb @@ -36,6 +36,8 @@ def enrich(page) set_description(page) set_icon_url(page) set_tags(page) + set_identifiers(page) + set_aliases(page) set_overridden_columns_label(page) page.data["releases"].each { |release| enrich_release(page, release) } @@ -84,6 +86,23 @@ def set_tags(page) page.data['tags'] = tags.sort end + # Set alias (derived from alternate_urls). + def set_aliases(page) + if page.data['alternate_urls'] + page.data['aliases'] = page.data['alternate_urls'].map { |path| path[1..] } + else + page.data['alternate_urls'] = [] # should be in a separate method, but easier that way + page.data['aliases'] = [] + end + end + + # Set identifiers to empty if it's not present. + def set_identifiers(page) + if !page.data['identifiers'] + page.data['identifiers'] = [] + end + end + # Set properly the column presence/label if it was overridden. def set_overridden_columns_label(page) date_column_names = %w[releaseDateColumn releaseColumn discontinuedColumn eoasColumn eolColumn eoesColumn] @@ -321,8 +340,10 @@ def days_toward_now(date) return (date_timestamp - now_timestamp) / (60 * 60 * 24) end + # Template rendering function that replaces placeholders. + # The template is stripped to avoid unnecessary whitespaces in the output. def render_eol_template(template, cycle) - link = template.gsub('__RELEASE_CYCLE__', cycle['releaseCycle'] || '') + link = template.strip().gsub('__RELEASE_CYCLE__', cycle['releaseCycle'] || '') link.gsub!('__CODENAME__', cycle['codename'] || '') link.gsub!('__LATEST__', cycle['latest'] || '') link.gsub!('__LATEST_RELEASE_DATE__', cycle['latestReleaseDate'] ? cycle['latestReleaseDate'].iso8601 : '') diff --git a/_redirects b/_redirects index 661ebe047d2..b4a92b88ce0 100644 --- a/_redirects +++ b/_redirects @@ -9,35 +9,41 @@ # Setting a layout forces Jekyll to render this file layout: null --- -{%- for page in site.pages -%} +{% for page in site.pages -%} # Redirects for {{page.path}} {%- if page.alternate_urls %} {%- for url in page.alternate_urls %} {{url}} {{page.permalink}} {%- endfor %} {%- endif %} - {%- if page.layout == 'product' %} + {%- if page.layout == 'product' %} {{page.permalink}}/_edit https://github.com/endoflife-date/endoflife.date/edit/master/{{page.path}} +{%- comment %}API v0 redirect{% endcomment %} /api{{page.permalink}} /api{{page.permalink}}.json {%- if page.alternate_urls %} {%- for url in page.alternate_urls %} /calendar{{url}}.ics /calendar{{page.permalink}}.ics /api{{url}}.json /api{{page.permalink}}.json /api{{url}}/* /api{{page.permalink}}/:splat +/api/v1/products{{url}}/* /api/v1/products{{page.permalink}}/:splat {%- endfor %} {%- endif %} {%- endif %} {% endfor %} -# Clients will try to access /favicon.ico, in some scenarios -# we don't want the file in our codebase, because the theme -# embeds it as a favicon, so instead set a redirect for -# these clients to a PNG file instead. +# Clients will try to access /favicon.ico, in some scenarios we don't want the file in our codebase, +# because the theme embeds it as a favicon, so instead set a redirect for these clients to a PNG file instead. /favicon.ico /assets/favicon-32x32.png -# Send API 404 responses in JSON -/api/* /assets/404.json 404 +# Rewrite for /api/v1/ to keep URLs clean. +# All API responses are located in an index.json and must be accessible without the file name, such as: +# - /api/v1/index.json -> /api/v1/ +# - /api/v1/products/almalinux/index.json -> /api/v1/products/almalinux/ +# This uses shadowing : https://docs.netlify.com/routing/redirects/rewrites-proxies/#shadowing, and +# it must be declared at the end of the file to not take precedence on the redirects (see +# https://docs.netlify.com/routing/redirects/#rule-processing-order). +/api/v1/* /api/v1/:splat/index.json 200! # A few permanent redirects for removed pages /tags/api-gateway /tags/web-server diff --git a/api_v1/openapi.yml b/api_v1/openapi.yml new file mode 100644 index 00000000000..4fae130bba3 --- /dev/null +++ b/api_v1/openapi.yml @@ -0,0 +1,535 @@ +--- +# API v1 description. See https://spec.openapis.org/oas/v3.1.0 for specification. +# Edit using https://editor.swagger.io/. + +permalink: /docs/api/v1/openapi.yml +layout: null +--- +openapi: 3.0.3 + +info: + title: endoflife API + version: "1.0.0-b1" + license: + name: MIT License + url: 'https://github.com/endoflife-date/endoflife.date/blob/master/LICENSE' + description: >- + endoflife.date documents EOL dates and support lifecycles for various products. + The endoflife API allows users to discover and query for those products. + + + Some useful links: + + - [The endoflife.date website](https://endoflife.date/) + + - [The endoflife.date repository](https://github.com/endoflife-date/endoflife.date) + + - [The endoflife.date issue tracker](https://github.com/endoflife-date/endoflife.date/issues/) + + - [The source API definition](https://github.com/endoflife-date/endoflife.date/blob/master/assets/openapi.yml) + +# Replace with your preview URL (such as https://deploy-preview-2080--endoflife-date.netlify.app/api/v1). +servers: + - url: {{ site.url }}/api/v1 + +paths: + /: + get: + summary: List the main endoflife.date API endpoints. + responses: + '200': + description: successful operation + headers: {} + content: + application/json: + schema: + $ref: '#/components/schemas/UriListResponse' + + /products: + get: + summary: > + List all the products referenced on endoflife.date. + Only a subset of each product's data is returned by this endpoint. + responses: + '200': + description: successful operation + headers: {} + content: + application/json: + schema: + $ref: '#/components/schemas/ProductListResponse' + + /products/full/: + get: + summary: > + List all the products referenced on endoflife.date, with all their details. + The full products data is returned by this endpoint, making the result a dump of nearly all + endoflife.date data. + responses: + '200': + description: successful operation + headers: {} + content: + application/json: + schema: + $ref: '#/components/schemas/FullProductListResponse' + + /products/{product}/: + get: + summary: > + Get the given product data. + This endpoint is returning all endoflife.date knows about the product, including release + cycles data. + parameters: + - name: product + in: path + description: 'The name of the product.' + required: true + schema: + type: string + responses: + '200': + description: successful operation + headers: {} + content: + application/json: + schema: + $ref: '#/components/schemas/ProductResponse' + + /products/{product}/cycles/{cycle}: + get: + summary: Get the given product release cycle data. + parameters: + - name: product + in: path + description: 'The name of the product.' + required: true + schema: + type: string + - name: cycle + in: path + description: 'The name of the cycle.' + required: true + schema: + type: string + responses: + '200': + description: successful operation + headers: {} + content: + application/json: + schema: + $ref: '#/components/schemas/ProductCycleResponse' + + /products/{product}/cycles/latest/: + get: + summary: Get the latest release cycle data for the given product. + parameters: + - name: product + in: path + description: 'The name of the product.' + required: true + schema: + type: string + responses: + '200': + description: successful operation + headers: {} + content: + application/json: + schema: + $ref: '#/components/schemas/ProductCycleResponse' + + /categories: + get: + summary: List all endoflife.date categories. + responses: + '200': + description: successful operation + headers: {} + content: + application/json: + schema: + $ref: '#/components/schemas/UriListResponse' + + /categories/{category}: + get: + summary: > + List all the products referenced on endoflife.date for the given category. + Only a subset of each product's data is returned by this endpoint. + parameters: + - name: category + in: path + description: 'The name of the category.' + required: true + schema: + type: string + responses: + '200': + description: successful operation + headers: {} + content: + application/json: + schema: + $ref: '#/components/schemas/ProductListResponse' + + /tags: + get: + summary: List all endoflife.date tags. + responses: + '200': + description: successful operation + headers: {} + content: + application/json: + schema: + $ref: '#/components/schemas/UriListResponse' + + /tags/{tag}: + get: + summary: > + List all the products referenced on endoflife.date for the given tag. + Only a subset of each product's data is returned by this endpoint. + parameters: + - name: tag + in: path + description: 'The name of the tag.' + required: true + schema: + type: string + responses: + '200': + description: successful operation + headers: {} + content: + application/json: + schema: + $ref: '#/components/schemas/ProductListResponse' + +# Responses must be at the end of the list, contain a schema_version property and be suffixed with +# 'Response' to facilitate maintenance and reading. +components: + schemas: + Uri: + type: object + properties: + name: + type: string + description: Name of the URI + example: tags + uri: + type: string + format: uri + description: URI + example: {{ site.url }}/tags/ + + Identifier: + type: object + properties: + name: + type: type + description: Type of the identifier (types as of 2023-03 are repology, purl and cpe) + example: cpe + id: + type: string + description: Identifier + example: cpe:/o:canonical:ubuntu_linux + + ProductVersion: + type: object + properties: + name: + type: string + description: Name of the version. + example: "22.04.2" + date: + type: string + format: date + description: Release date. + example: "2022-04-21" + link: + type: string + format: uri + description: Link to the changelog or release notes. + example: https://wiki.ubuntu.com/JammyJellyfish/ReleaseNotes/ + + ProductCycle: + type: object + properties: + name: + type: string + description: Name of the product cycle. + example: "22.04" + codename: + type: string + nullable: true + description: Name of the product cycle. + example: Jammy Jellyfish + label: + type: string + description: Label of the product cycle. + example: 22.04 'Jammy Jellyfish' (LTS) + date: + type: string + format: date + description: Release date of the cycle. + example: "2022-04-21" + isLts: + ype: boolean + description: Whether the product cycle is LTS. + example: true + ltsFrom: + type: string + format: date + nullable: true + description: Start date of the LTS phase. + example: "2022-04-21" + isActiveSupportOver: + type: boolean + nullable: true + description: Whether the active support phase is over. + example: false + activeSupportUntil: + type: string + format: date + nullable: true + description: End date of the active support phase. + example: "2024-09-30" + isEol: + type: boolean + nullable: true + description: Whether the product cycle is EOL. + example: false + eolFrom: + type: string + format: date + nullable: true + description: End of life date for the product cycle. + example: "2027-04-01" + isDiscontinued: + type: boolean + nullable: true + description: Whether the product cycle is discontinued (mainly used for hardware). + example: false + discontinuedFrom: + type: string + format: date + nullable: true + description: Discontinuation date (mainly used for hardware). + example: "2027-04-01" + isExtendedSupportOver: + type: boolean + nullable: true + description: Whether the extended support phase is over. + example: true + extendedSupportUntil: + type: string + format: date + nullable: false + description: End date of the extended support phase. + example: "2032-04-09" + isMaintained: + type: boolean + nullable: false + description: Whether or not this cycle still have some level of support. This could be any level of support, even extended support. + example: true + latest: + type: object + $ref: '#/components/schemas/ProductVersion' + + ProductSummary: + type: object + properties: + name: + type: string + description: Name of the product + example: ubuntu + label: + type: string + description: Label of the product + example: Ubuntu + aliases: + type: array + description: Aliases declared for the product (derived from alternate_urls) + items: + type: string + category: + type: string + description: Category of the product + example: os + tags: + type: array + description: Tags associated to the product + items: + type: string + uri: + type: string + format: uri + description: Link to the full product details + example: {{ site.url }}/api/v1/products/ubuntu/ + + ProductDetails: + type: object + properties: + name: + type: string + description: Name of the product + example: ubuntu + label: + type: string + description: Label of the product + example: Ubuntu + aliases: + type: array + description: Aliases declared for the product (derived from alternate_urls) + items: + type: string + category: + type: string + description: Category of the product + example: os + tags: + type: array + description: Tags associated to the product + items: + type: string + # Additional properties (compared to ProductSummary) + versionCommand: + type: string + description: Command that can be used to check the current product version. + example: lsb_release --release + identifiers: + type: array + description: Known identifiers (purl, repology, cpe...) associated to the product + items: + $ref: '#/components/schemas/Identifier' + labels: + type: object + description: Product labels. + properties: + activeSupport: + type: string + nullable: true + description: Label used to denote the active support phase. + example: Hardware & Maintenance + discontinued: + type: string + nullable: true + description: Label used to denote the discontinuation of the product. + example: Discontinued + eol: + type: string + nullable: true + description: Label used to denote the phase before the EOL of the product. + example: Maintenance & Security Support + extendedSupport: + type: string + nullable: true + description: Label used to denote the extended support phase. + example: Extended Security Maintenance + links: + type: object + description: Product links. + properties: + icon: + type: string + format: uri + nullable: true + description: Link to the product icon (on https://simpleicons.org/). + example: https://simpleicons.org/icons/ubuntu.svg + html: + type: string + format: uri + description: Link to the product page on endoflife.date. + example: https://endoflife.date/ubuntu + releasePolicy: + type: string + format: uri + nullable: true + description: Link to the product release policy. + example: https://wiki.ubuntu.com/Releases + cycles: + type: array + description: Product release cycles. + items: + $ref: '#/components/schemas/ProductCycle' + + UriListResponse: + type: object + properties: + schema_version: + type: string + description: Version of this schema. + example: 1.0.0 + total: + type: integer + format: int32 + description: Number of uri in the list. + example: 3 + result: + type: array + items: + $ref: '#/components/schemas/Uri' + + ProductListResponse: + type: object + properties: + schema_version: + type: string + description: Version of this schema. + example: 1.0.0 + total: + type: integer + format: int32 + description: Number of products in the list. + example: 200 + result: + type: array + items: + $ref: '#/components/schemas/ProductSummary' + + FullProductListResponse: + type: object + properties: + schema_version: + type: string + description: Version of this schema. + example: 1.0.0 + total: + type: integer + format: int32 + description: Number of products in the list. + example: 200 + result: + type: array + items: + $ref: '#/components/schemas/ProductDetails' + + ProductCycleResponse: + type: object + properties: + schema_version: + type: string + description: Version of this schema. + example: 1.0.0 + result: + $ref: '#/components/schemas/ProductCycle' + + ProductResponse: + type: object + properties: + schema_version: + type: string + description: Version of this schema. + example: 1.0.0 + last_modified: + type: string + format: date-time + description: The time this product was last modified. + example: 2023-03-01T14:05:52+01:00 + auto: + type: boolean + description: Whether or not product versions are automatically updated. + example: true + result: + $ref: '#/components/schemas/ProductDetails' diff --git a/api_v1/swagger-ui.md b/api_v1/swagger-ui.md new file mode 100644 index 00000000000..bd2a634e7b0 --- /dev/null +++ b/api_v1/swagger-ui.md @@ -0,0 +1,6 @@ +--- +title: EndOfLife API v1 Swagger UI +permalink: /docs/api/v1/ +openapi_yml: /docs/api/v1/openapi.yml +layout: swagger-ui +--- diff --git a/assets/404.json b/assets/404.json deleted file mode 100644 index 0238a1f9945..00000000000 --- a/assets/404.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "message": "Product not found" -} \ No newline at end of file diff --git a/humans.txt b/humans.txt index 33ff45aba20..4843c09bdbd 100644 --- a/humans.txt +++ b/humans.txt @@ -5,5 +5,5 @@ Contributors: https://github.com/endoflife-date/endoflife.date/graphs/contributo /* SITE */ Software: Jekyll, Netlify, GitHub, Ruby, GitHub Actions -Components: Just the Docs Jekyll Theme, Stoplight Elements, Simple Icons +Components: Just the Docs Jekyll Theme, Stoplight Elements, Swagger UI, Simple Icons Logo: adaptation of "An hourglass in a round icon" by David Abián and Serhio Magpie (https://commons.wikimedia.org/wiki/File:Hourglass_icon_%28orange%29.svg) diff --git a/index.md b/index.md index 3776fcbe14c..6bfd22124bf 100644 --- a/index.md +++ b/index.md @@ -11,7 +11,7 @@ End-of-life (EOL) and support information is [often hard to track, or very badly endoflife.date documents EOL dates and support lifecycles for various products. endoflife.date aggregates data from various sources and presents it in an understandable and -succinct manner. It also makes the data available using an [easily accessible API](https://endoflife.date/docs/api) +succinct manner. It also makes the data available using an [easily accessible API](/docs/api/v1/) and has iCalendar support. endoflife.date currently tracks {{ site.pages | where: "layout", "product" | size }} products. @@ -41,7 +41,7 @@ If you maintain release information for a product (end-of-life dates or support also have a [set of recommendations](/recommendations) along with a checklist on some best practices for publishing this information. -And do not hesitate to [play with our API](https://endoflife.date/docs/api). Here are a few awesome +And do not hesitate to [play with our API](/docs/api/v1/). Here are a few awesome tools that already did it: [norwegianblue](https://github.com/hugovk/norwegianblue), [end_of_life](https://github.com/MatheusRich/end_of_life), and [cicada](https://github.com/mcandre/cicada). Find more on From 2d329ea84de0b81c87d90c18f9c7513f546026cc Mon Sep 17 00:00:00 2001 From: Marc Wrobel Date: Sun, 23 Jun 2024 14:09:23 +0200 Subject: [PATCH 3/3] Renames following #4931 --- CHANGELOG_API.md | 4 ++-- _plugins/generate-api-v1.rb | 12 ++++++------ api_v1/openapi.yml | 22 +++++++++++----------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/CHANGELOG_API.md b/CHANGELOG_API.md index 3bc9e66b4bd..04d89e0e5d0 100644 --- a/CHANGELOG_API.md +++ b/CHANGELOG_API.md @@ -13,10 +13,10 @@ v1: - new computed fields such as `is_maintained`, - the replacement of fields that were using union types with two separate single-type fields: - `lts` -> `isLts` and `ltsFrom`, - - `support` -> `isActiveSupportOver` and `activeSupportUntil`, + - `support` -> `isEoas` and `eoasFrom`, - `eol` -> `isEol` and `eolFrom`, - `discontinued` -> `isDiscontinued` and `discontinuedFrom`, - - `extendedSupport` -> `isExtendedSupportOver` and `extendedSupportUntil`. + - `extendedSupport` -> `isEoes` and `eoesFrom`. - provide new endpoints (#2078, #2160, #2530) - is versioned using the `api/v1` prefix (#2066), making it easier to implement non-backward-compatible changes in the future, diff --git a/_plugins/generate-api-v1.rb b/_plugins/generate-api-v1.rb index f979c5b44cd..0bb388a9eee 100755 --- a/_plugins/generate-api-v1.rb +++ b/_plugins/generate-api-v1.rb @@ -214,10 +214,10 @@ def product_to_json(site, product) id: identifier.values.first } }, labels: { - "activeSupport": product.data['activeSupportColumn'] ? ApiV1.strip_html(product.data['activeSupportColumnLabel']) : nil, + "eoas": product.data['eoasColumn'] ? ApiV1.strip_html(product.data['eoasColumnLabel']) : nil, "discontinued": product.data['discontinuedColumn'] ? ApiV1.strip_html(product.data['discontinuedColumnLabel']) : nil, "eol": product.data['eolColumn'] ? ApiV1.strip_html(product.data['eolColumnLabel']) : nil, - "extendedSupport": product.data['extendedSupportColumn'] ? ApiV1.strip_html(product.data['extendedSupportColumnLabel']) : nil, + "eoes": product.data['eoesColumn'] ? ApiV1.strip_html(product.data['eoesColumnLabel']) : nil, }, links: { icon: product.data['iconUrl'], @@ -249,14 +249,14 @@ def cycle_to_json(cycle) date: cycle['releaseDate'], isLts: cycle['is_lts'], ltsFrom: cycle['lts_from'], - isActiveSupportOver: cycle['is_active_support_over'], - activeSupportUntil: cycle['active_support_until'], + isEoas: cycle['is_eoas'], + eoasFrom: cycle['eoas_from'], isEol: cycle['is_eol'], eolFrom: cycle['eol_from'], isDiscontinued: cycle['is_discontinued'], discontinuedFrom: cycle['discontinued_from'], - isExtendedSupportOver: cycle['is_extended_support_over'], - extendedSupportUntil: cycle['extended_support_until'], + isEoes: cycle['is_eoes'], + eoesFrom: cycle['eoes_from'], isMaintained: cycle['is_maintained'], latest: { name: cycle['latest'], diff --git a/api_v1/openapi.yml b/api_v1/openapi.yml index 4fae130bba3..31e23262005 100644 --- a/api_v1/openapi.yml +++ b/api_v1/openapi.yml @@ -284,16 +284,16 @@ components: nullable: true description: Start date of the LTS phase. example: "2022-04-21" - isActiveSupportOver: + isEoas: type: boolean nullable: true description: Whether the active support phase is over. example: false - activeSupportUntil: + eoasFrom: type: string format: date nullable: true - description: End date of the active support phase. + description: End of active support date for the product cycle. example: "2024-09-30" isEol: type: boolean @@ -317,16 +317,16 @@ components: nullable: true description: Discontinuation date (mainly used for hardware). example: "2027-04-01" - isExtendedSupportOver: + isEoes: type: boolean nullable: true description: Whether the extended support phase is over. example: true - extendedSupportUntil: + eoesFrom: type: string format: date nullable: false - description: End date of the extended support phase. + description: End of extended support date for the product cycle. example: "2032-04-09" isMaintained: type: boolean @@ -407,10 +407,10 @@ components: type: object description: Product labels. properties: - activeSupport: + eoas: type: string nullable: true - description: Label used to denote the active support phase. + description: Label used to denote the phase before the end of active support of the product. example: Hardware & Maintenance discontinued: type: string @@ -420,12 +420,12 @@ components: eol: type: string nullable: true - description: Label used to denote the phase before the EOL of the product. + description: Label used to denote the phase before the end of life of the product. example: Maintenance & Security Support - extendedSupport: + eoes: type: string nullable: true - description: Label used to denote the extended support phase. + description: Label used to denote the phase before the end of extended support of the product. example: Extended Security Maintenance links: type: object