diff --git a/.github/awsl-layer-docker/Dockerfile b/.github/awsl-layer-docker/Dockerfile index c66683d..024e221 100644 --- a/.github/awsl-layer-docker/Dockerfile +++ b/.github/awsl-layer-docker/Dockerfile @@ -1,4 +1,4 @@ -FROM lambci/lambda:20200812-build-ruby2.7 as builder +FROM public.ecr.aws/sam/build-ruby:3.4 AS builder ARG INTERSCRIPT_GEM_VERSION RUN echo "using INTERSCRIPT_GEM_VERSION=$INTERSCRIPT_GEM_VERSION" @@ -6,24 +6,7 @@ RUN echo "using INTERSCRIPT_GEM_VERSION=$INTERSCRIPT_GEM_VERSION" ADD Gemfile . RUN bundle config set path "/vendor" RUN bundle install --without=development --jobs 4 --retry 3 -#RUN ls -all /venfor/ruby/2.7.0/bundler/gems -# RUN echo "install python libs" -#RUN PYTHONUSERBASE=/lambda/lib/python3.7 pip3 install --user install \ -# setuptools \ -# numpy \ -# git+https://github.com/sequitur-g2p/sequitur-g2p@9b7c505 +FROM public.ecr.aws/sam/build-ruby:3.4 AS yumda -# ruby/gems/2.7.0/gems -FROM lambci/yumda:2 as yumda - -#RUN yum install -y \ -# python3 \ -# pip3 \ -# swig \ -# python3-setuptools - -#COPY --from=builder /lambda/ /lambda/ -#COPY --from=builder /ruby/2.7.0 /lambda/opt/ruby/gems/2.7.0 COPY --from=builder /vendor/ruby /lambda/opt/ruby/gems/ - diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9eb31f3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + - package-ecosystem: bundler + directory: "/" + schedule: + interval: weekly diff --git a/.github/lambda/Dockerfile b/.github/lambda/Dockerfile index 115970d..7847b61 100644 --- a/.github/lambda/Dockerfile +++ b/.github/lambda/Dockerfile @@ -1,16 +1,15 @@ -FROM lambci/lambda:20200812-build-ruby2.7 as builder +FROM public.ecr.aws/sam/build-ruby:3.4 AS builder + ARG INTERSCRIPT_GEM_VERSION RUN echo "using INTERSCRIPT_GEM_VERSION=$INTERSCRIPT_GEM_VERSION" COPY src . ENV INTERSCRIPT_GEM_VERSION=${INTERSCRIPT_GEM_VERSION} ENV GEM_HOME=${LAMBDA_TASK_ROOT} -#RUN echo $LAMBDA_TASK_ROOT -#RUN bundle config set path "/vendor" RUN bundle install --without=development --jobs 4 --retry 3 -FROM public.ecr.aws/lambda/ruby:2.7 +FROM public.ecr.aws/lambda/ruby:3.4 ARG INTERSCRIPT_GEM_VERSION RUN echo "using INTERSCRIPT_GEM_VERSION=$INTERSCRIPT_GEM_VERSION" @@ -23,8 +22,5 @@ ENV INTERSCRIPT_GEM_VERSION=${INTERSCRIPT_GEM_VERSION} COPY --from=builder ${LAMBDA_TASK_ROOT} ${LAMBDA_TASK_ROOT} COPY src ${LAMBDA_TASK_ROOT} -#COPY --from=builder /vendor/ruby/2.7.0/gems ${LAMBDA_TASK_ROOT}/gems -#COPY --from=builder /vendor/ruby/2.7.0/gems /var/lang/lib/ruby/gems/2.7.0/gems -# /var/lang/lib/ruby/gems/2.7.0/gems -# COPY --from=builder /vendor/ruby /var/task/opt/ruby/gems/ + CMD [ "lib/interscript-api/lambda_function.handler" ] diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..8be1696 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,27 @@ +name: codeql + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "0 0 * * 0" # weekly + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [ruby] + steps: + - uses: actions/checkout@v7 + - uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/manual-release.yml b/.github/workflows/manual-release.yml index 05b66c1..ed0d84b 100644 --- a/.github/workflows/manual-release.yml +++ b/.github/workflows/manual-release.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: clone it - uses: actions/checkout@v2 + uses: actions/checkout@v7 with: submodules: true @@ -39,7 +39,7 @@ jobs: echo "API_TAG_NAME=$API_TAG_NAME" >> ${GITHUB_ENV} - name: make new release - uses: softprops/action-gh-release@v0.1.7 + uses: softprops/action-gh-release@v3 with: token: ${{ env.GITHUB_PUSH_TOKEN }} tag_name: ${{env.API_TAG_NAME}} diff --git a/.github/workflows/on-api-release.yml b/.github/workflows/on-api-release.yml index 5b8470c..ae8b79e 100644 --- a/.github/workflows/on-api-release.yml +++ b/.github/workflows/on-api-release.yml @@ -10,42 +10,52 @@ env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} +permissions: + contents: read + packages: write + jobs: build-release: runs-on: ubuntu-latest # id=68949242 => interscript-ci user - if: ${{ github.event.release.author.id == 68949242 }} #contains(github.event.release.body, '#releaseed-by-ci') + if: ${{ github.event.release.author.id == 68949242 }} steps: - - uses: actions/checkout@v2 - - name: setup env vars + - uses: actions/checkout@v7 + + - name: Extract gem version from release body run: | RELEASE_BODY="${{github.event.release.body}}" - echo "RELEASE_BODY=$RELEASE_BODY" >> ${GITHUB_ENV} RELEASE_TAG_NAME="${{github.event.release.tag_name}}" - echo "RELEASE_TAG_NAME=$RELEASE_TAG_NAME" >> ${GITHUB_ENV} GEM_VERSION=$(echo "$RELEASE_BODY" | grep -Po '(?<=v)[^;]+') + echo "RELEASE_TAG_NAME=$RELEASE_TAG_NAME" >> ${GITHUB_ENV} echo "GEM_VERSION=$GEM_VERSION" >> ${GITHUB_ENV} echo "INTERSCRIPT_GEM_VERSION=$GEM_VERSION" >> ${GITHUB_ENV} - - name: prepare docker context + - name: Prepare docker context working-directory: .github/lambda - run: | - bash build.sh + run: bash build.sh - - name: push to github package - uses: elgohr/Publish-Docker-Github-Action@master + - name: Log in to GHCR + uses: docker/login-action@v4 with: - workdir: .github/lambda - dockerfile: Dockerfile - tags: "latest,${{env.RELEASE_TAG_NAME}}" - registry: "docker.pkg.github.com" - name: "${{github.repository}}/awslambda-interscript-api" - username: "${{github.actor}}" - password: "${{env.GITHUB_PUSH_TOKEN}}" - buildargs: INTERSCRIPT_GEM_VERSION + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ env.GITHUB_PUSH_TOKEN }} - - name: dispatch "infrastructure-lambda-api" - uses: peter-evans/repository-dispatch@v1 + - name: Build and push Lambda image + uses: docker/build-push-action@v7 + with: + context: .github/lambda + file: .github/lambda/Dockerfile + push: true + build-args: | + INTERSCRIPT_GEM_VERSION=${{ env.GEM_VERSION }} + tags: | + ghcr.io/${{ github.repository }}/awslambda-interscript-api:latest + ghcr.io/${{ github.repository }}/awslambda-interscript-api:${{ env.RELEASE_TAG_NAME }} + + - name: Dispatch "infrastructure-lambda-api" + uses: peter-evans/repository-dispatch@v4 with: token: ${{ env.GITHUB_PUSH_TOKEN }} repository: interscript/infrastructure-lambda-api @@ -55,88 +65,3 @@ jobs: "workflow": "publish-ecr.yml", "api_tag_name": "${{env.RELEASE_TAG_NAME}}" } - -# - name: setup-aws-credentials -# uses: aws-actions/configure-aws-credentials@v1 -# with: -# aws-access-key-id: ${{ env.AWS_ACCESS_KEY_ID }} -# aws-secret-access-key: ${{ env.AWS_SECRET_ACCESS_KEY }} -# aws-region: us-east-1 -# -# - name: ecr-login -# id: login-ecr -# uses: aws-actions/amazon-ecr-login@v1 -# -# - name: push to ecr -# env: -# ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} -# working-directory: .github/lambda -# run: | -# docker build -t $ECR_REGISTRY/interscript-api:${{env.RELEASE_TAG_NAME}} . -# docker push $ECR_REGISTRY/$ECR_REPOSITORY:${{env.RELEASE_TAG_NAME}} - - - - -# - name: push to github package -# uses: elgohr/Publish-Docker-Github-Action@master -# with: -# workdir: .github/lambda -# dockerfile: Dockerfile -# tags: "latest,${{env.RELEASE_TAG_NAME}}" -# registry: "" -# name: "awslambda-interscript-api" -# username: "${{env.AWS_ACCESS_KEY_ID}}" -# password: "${{env.}}" -# buildargs: INTERSCRIPT_GEM_VERSION - -# - name: login github package -# uses: docker/login-action@v1 -# with: -# username: ${{ secrets.DOCKERHUB_USERNAME }} -# password: ${{ secrets.DOCKERHUB_TOKEN }} -# - name: build and push -# id: docker_build -# uses: docker/build-push-action@v2 -# with: -# push: true -# tags: user/app:latest - -# - name: setup ruby+install -# uses: ruby/setup-ruby@v1.75.0 -# with: -# bundler-cache: false -# ruby-version: 2.7 - -# - name: build "-api" source -# working-directory: git -# run: | -# cp Gemfile* lib/ -# zip -r ${AWSL_SRC_ZIP} lib - -# - name: build "-layer" -# working-directory: git/.github/awsl-layer-docker -# run: | -# ls -all -# bash build.sh -# cp libs.zip $AWSL_LAYER_ZIP - -# - name: upload lambda layer to current release -# uses: softprops/action-gh-release@v1 -# with: -# tag_name: ${{env.RELEASE_TAG_NAME}} -# files: | -# ${{env.AWSL_SRC_ZIP}} -# ${{env.AWSL_LAYER_ZIP}} - -# - name: dispatch "infrastructure-lambda-api" -# uses: peter-evans/repository-dispatch@v1 -# with: -# token: ${{ env.GITHUB_PUSH_TOKEN }} -# repository: interscript/infrastructure-lambda-api -# event-type: ${{ github.repository }} -# client-payload: >- -# { -# "workflow": "on-api-release.yml", -# "api_tag_name": "${{env.RELEASE_TAG_NAME}}" -# } diff --git a/.github/workflows/on-interscript-release.yml b/.github/workflows/on-interscript-release.yml index cb1b565..5ea7498 100644 --- a/.github/workflows/on-interscript-release.yml +++ b/.github/workflows/on-interscript-release.yml @@ -4,31 +4,9 @@ on: repository_dispatch: types: [ interscript/interscript ] -env: - GITHUB_PUSH_TOKEN: ${{ secrets.INTERSCRIPT_CI_PAT }} - jobs: create-release: runs-on: ubuntu-latest if: startsWith(github.event.client_payload.ref, 'refs/tags/v') steps: - run: echo "disabled for now" -# - uses: actions/checkout@v1 -# -# - name: Setup env-vars -# run: | -# REF="${{ github.event.client_payload.ref }}" -# INTERSCRIPT_VERSION=${REF#refs/*/v} -# echo "INTERSCRIPT_VERSION=$INTERSCRIPT_VERSION" >> $GITHUB_ENV -# echo "API_TAG_NAME=$INTERSCRIPT_VERSION" >> $GITHUB_ENV -# -# - name: Create API Release -# uses: actions/create-release@v1 -# with: -# token: ${{ env.GITHUB_PUSH_TOKEN }} -# tag_name: v${{env.API_TAG_NAME}} -## release_name: Release v${{env.INTERSCRIPT_VERSION}} -## body: Auto release API v${{env.INTERSCRIPT_VERSION}} by @interscript-ci -# body: "#release-by-ci for Interscript Gem v${{env.INTERSCRIPT_VERSION}}" -# draft: false -# prerelease: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5769e6b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + rspec: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: ["3.4"] # Ruby 4.0 readiness: target latest stable 3.x line + env: + INTERSCRIPT_GEM_VERSION: "0.1.9" + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - run: bundle exec rspec + + standard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4" + bundler-cache: true + - run: bundle exec standardrb diff --git a/.ruby-version b/.ruby-version index e6b7b62..7921bd0 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.7 \ No newline at end of file +3.4.8 diff --git a/.standard.yml b/.standard.yml new file mode 100644 index 0000000..b2d534e --- /dev/null +++ b/.standard.yml @@ -0,0 +1,3 @@ +ignore: + - "vendor/**/*" + - "tmp/**/*" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1d79169 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [Latest] + +See GitHub releases for detailed release notes: https://github.com/interscript/interscript-api/releases diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..388ac39 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing + +Thanks for your interest in contributing! + +## Development setup + +```bash +git clone +cd +bundle install # Ruby projects +# or +npm ci # JS projects +``` + +## Workflow + +1. Fork → branch from `main` +2. Make changes with tests +3. Run `bundle exec rspec` (Ruby) or `npm test` (JS) locally +4. Run `bundle exec standardrb` (Ruby) or `npm run lint` (JS) +5. Open a PR with a clear description + +## Code style + +- Ruby: enforced by [StandardRB](https://github.com/standardrb/standard) +- JavaScript: enforced by ESLint + Prettier +- Python: enforced by ruff + +## Commit messages + +Use [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat: add new transliteration system +fix: correct off-by-one in CALT lookup +chore: bump dependencies +docs: clarify README +``` + +## Releases + +Maintainers tag releases following semver. CI publishes on tag push. diff --git a/Gemfile b/Gemfile index 517a205..fcd63cd 100644 --- a/Gemfile +++ b/Gemfile @@ -1,18 +1,22 @@ source "https://rubygems.org" -ruby RUBY_VERSION +# Ruby 4.0 readiness: target the latest stable 3.x line. +# Ruby 3.4 is the current stable line (Ruby 4.0 not yet released as of 2026-07). +# The codebase is audited to use no deprecated 3.x APIs. +ruby ">= 3.4" -gem "graphql", "~>1.9" +gem "graphql", "~> 1.13" -unless ENV['INTERSCRIPT_GEM_VERSION'].empty? then - gem "interscript", ENV['INTERSCRIPT_GEM_VERSION'] +if ENV["INTERSCRIPT_GEM_VERSION"] && !ENV["INTERSCRIPT_GEM_VERSION"].empty? + gem "interscript", ENV["INTERSCRIPT_GEM_VERSION"] else gem "interscript", "0.1.9" end -group :development do +gem "rababa", "~> 0.1.1" + +group :development, :test do gem "rake" - gem "rspec" + gem "rspec", "~> 3.13" + gem "standard", require: false end - -gem 'rababa', '~> 0.1.1' diff --git a/README.adoc b/README.adoc index f15e159..5e6c1d6 100644 --- a/README.adoc +++ b/README.adoc @@ -1,3 +1,4 @@ +image:https://github.com/interscript/interscript-api/actions/workflows/test.yml/badge.svg["CI status", link="https://github.com/interscript/interscript-api/actions/workflows/test.yml"] = Interscript API package This is the API for Interscript. diff --git a/Rakefile b/Rakefile index cfdaabb..77f7861 100644 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,6 @@ require "bundler/setup" -require 'rspec/core/rake_task' +require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) -task :default => :spec \ No newline at end of file +task default: :spec diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4c1f49b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security Policy + +## Supported Versions + +The latest released version of this project receives security fixes. + +## Reporting a Vulnerability + +Please **do not** open public GitHub issues for security vulnerabilities. + +Report privately via one of: + +- **GitHub Security Advisories** — Security tab → "Report a vulnerability" (preferred) +- **Email** — open.source@ribose.com + +We acknowledge reports within 72 hours and aim to ship a fix within 30 days for critical issues. Coordinated disclosure is supported. + +## Disclosure + +Public disclosure happens after a fix is released, on a timeline agreed with the reporter. diff --git a/lib/interscript-api/error.rb b/lib/interscript-api/error.rb new file mode 100644 index 0000000..336c8ae --- /dev/null +++ b/lib/interscript-api/error.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module InterscriptApi + class Error < StandardError; end + + class InputTooLongError < Error + def initialize(max:) + super("Input exceeds maximum length of #{max} characters") + @max = max + end + + attr_reader :max + end +end diff --git a/lib/interscript-api/graphql/schema.rb b/lib/interscript-api/graphql/schema.rb index bfae15d..8f20f1e 100644 --- a/lib/interscript-api/graphql/schema.rb +++ b/lib/interscript-api/graphql/schema.rb @@ -1,7 +1,11 @@ -require_relative "types/query_type" +# frozen_string_literal: true + +require_relative "types/query" module InterscriptApi - class Schema < GraphQL::Schema - query QueryType + module GraphQL + class Schema < ::GraphQL::Schema + query Types::Query + end end end diff --git a/lib/interscript-api/graphql/types/detection_result.rb b/lib/interscript-api/graphql/types/detection_result.rb new file mode 100644 index 0000000..fe9c799 --- /dev/null +++ b/lib/interscript-api/graphql/types/detection_result.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "json" + +module InterscriptApi + module GraphQL + module Types + class DetectionResult < ::GraphQL::Schema::Object + field :map_name, String, null: false + field :distance, Float, null: false + end + end + end +end diff --git a/lib/interscript-api/graphql/types/query.rb b/lib/interscript-api/graphql/types/query.rb new file mode 100644 index 0000000..d644c62 --- /dev/null +++ b/lib/interscript-api/graphql/types/query.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "graphql" +require "json" + +require_relative "../../../limits" +require_relative "../../../version" +require_relative "../../../error" +require_relative "detection_result" + +require "interscript" +require "interscript/compiler/ruby" + +require "pathname" unless ENV["AWS_EXECUTION_ENV"].nil? + +module InterscriptApi + module GraphQL + module Types + class Query < ::GraphQL::Schema::Object + description "Root Query for this API" + + # -- Fields declared via class-level DSL; new fields are added here + # -- without modifying resolver methods of existing fields (OCP). + + field :info, String, null: true do + description "Get API information" + end + + field :system_codes, [String], null: true do + description "Get all current supported system_codes" + end + + field :transliterate, String, null: true do + description "Transliterate the given input using the specified system_code" + argument :system_code, String, required: true + argument :input, String, required: true + end + + field :detect, [DetectionResult], null: true do + description "Detect which transliteration system was used for input to generate output" + argument :input, String, required: true + argument :output, String, required: true + argument :map_pattern, String, required: false, default_value: "*" + end + + # -- Resolvers (one method per field; no cross-coupling) + + def transliterate(system_code:, input:) + unless input.length <= InterscriptApi::LIMITS[:input_max_size] + raise InterscriptApi::InputTooLongError.new(max: InterscriptApi::LIMITS[:input_max_size]) + end + + Interscript.transliterate( + system_code, + input, + query_cache, + compiler: Interscript::Compiler::Ruby + ) + end + + def detect(input:, output:, map_pattern: "*") + Interscript.detect( + input, + output, + compiler: Interscript::Compiler::Ruby, + multiple: true, + cache: query_cache, + map_pattern: map_pattern + ).map { |map_name, distance| {map_name: map_name, distance: distance} } + end + + def info + JSON.generate( + version: InterscriptApi::VERSION, + interscript_version: Interscript::VERSION + ) + end + + def system_codes + Interscript.maps + end + + private + + # Per-request cache, lazily initialized. Lives on the resolver instance + # for the lifetime of a single GraphQL execution. + def query_cache + @query_cache ||= {} + end + end + end + end +end diff --git a/lib/interscript-api/graphql/types/query_type.rb b/lib/interscript-api/graphql/types/query_type.rb deleted file mode 100644 index 209b132..0000000 --- a/lib/interscript-api/graphql/types/query_type.rb +++ /dev/null @@ -1,88 +0,0 @@ -require "graphql" -require_relative "../../../limits" -require_relative "../../../version" -require "interscript" -require "interscript/compiler/ruby" - -require 'pathname' unless ENV["AWS_EXECUTION_ENV"].nil? - -class DetectionResultType < GraphQL::Schema::Object - field :map_name, String, null: false - field :distance, Float, null: false -end - -class QueryType < GraphQL::Schema::Object - description "Root Query for this API" - - field :info, String, null: true do - description "Get API information" - end - - # field :limits, String, null: true do - # description "Get limits API information" - # end - - field :system_codes, [String], null: true do - description "Get all current supported system_codes" - end - - field :transliterate, String, null: true do - description "Transliterate #input using #system_code" - argument :system_code, String, required: true - argument :input, String, required: true - end - - def transliterate(system_code:, input:) - if input.length > InterscriptApi::LIMITS[:input_max_size] - raise StandardError.new("{input} string too long") - end - - rs = Interscript.transliterate( - system_code, - input.dup, - @cache ||= {}, - compiler: Interscript::Compiler::Ruby - ) - rs - end - - field :detect, [DetectionResultType], null: true do - description "Detect which transliteration system was used for #input to generate #output" - argument :input, String, required: true - argument :output, String, required: true - - argument :map_pattern, String, required: false, default_value: '*' - end - - def detect(input:, output:, map_pattern: "*") - rs = Interscript.detect( - input, - output, - compiler: Interscript::Compiler::Ruby, - multiple: true, - cache: @cache ||= {}, - map_pattern: map_pattern - ).map do |map_name, distance| - { map_name: map_name, distance: distance } - end - - rs - end - - def info - JSON.generate({ - version: InterscriptApi::VERSION, - interscript_version: Interscript::VERSION, - }) - end - - # def limits - # JSON.generate InterscriptApi::LIMITS - # end - - def system_codes - Interscript.maps - end -end - - diff --git a/lib/interscript-api/lambda/cors.rb b/lib/interscript-api/lambda/cors.rb new file mode 100644 index 0000000..39fb3e3 --- /dev/null +++ b/lib/interscript-api/lambda/cors.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "json" + +module InterscriptApi + module Lambda + # Builds CORS-aware response headers based on the request origin. + # + # Separated from the handler so CORS rules can be tested independently + # of the GraphQL execution path. + module Cors + DEFAULT_HEADERS = { + "Access-Control-Allow-Origin" => "*", + "Access-Control-Allow-Headers" => "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token", + "Access-Control-Allow-Methods" => "POST, OPTIONS" + }.freeze + + module_function + + def headers_for(event:) + default_origin = ENV.fetch("DEFAULT_ORIGIN", "*") + regex = ENV["CORS_ORIGIN_REGEX"] + origin = event.fetch("headers", {}).fetch("origin", "") + + if regex && !regex.empty? && origin && /#{regex}/.match?(origin) + DEFAULT_HEADERS.merge("Access-Control-Allow-Origin" => origin) + else + DEFAULT_HEADERS.merge("Access-Control-Allow-Origin" => default_origin) + end + end + end + end +end diff --git a/lib/interscript-api/lambda/request_parser.rb b/lib/interscript-api/lambda/request_parser.rb new file mode 100644 index 0000000..2601651 --- /dev/null +++ b/lib/interscript-api/lambda/request_parser.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module InterscriptApi + module Lambda + # Extracts the GraphQL query string from a Lambda event body. + # + # Single responsibility: turn the raw event body into a query string, + # accepting either a JSON `{"query": "..."}` envelope or a raw string. + module RequestParser + module_function + + def extract_query(body:) + return nil if body.nil? || body.empty? + + json = begin + JSON.parse(body) + rescue JSON::ParserError + nil + end + + json.is_a?(Hash) ? json["query"] : body + end + end + end +end diff --git a/lib/interscript-api/lambda/response_builder.rb b/lib/interscript-api/lambda/response_builder.rb new file mode 100644 index 0000000..6015485 --- /dev/null +++ b/lib/interscript-api/lambda/response_builder.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "json" + +module InterscriptApi + module Lambda + # Shapes the result of GraphQL execution into a Lambda-compatible + # response hash. Centralises status-code and body conventions so the + # handler can stay focused on orchestration. + module ResponseBuilder + DEFAULT_OK_STATUS = 200 + DEFAULT_BAD_REQUEST_STATUS = 400 + + def self.success(body:, headers:) + new(status: DEFAULT_OK_STATUS, body: body, headers: headers) + end + + def self.bad_request(body:, headers:) + new(status: DEFAULT_BAD_REQUEST_STATUS, body: body, headers: headers) + end + + def self.options(headers:) + new(status: DEFAULT_OK_STATUS, body: nil, headers: headers) + end + + def self.build(status:, body:, headers:) + new(status: status, body: body, headers: headers).to_h + end + + def self.status_for(result_body:, fallback: DEFAULT_OK_STATUS) + parsed = JSON.parse(result_body) + parsed.is_a?(Hash) && parsed.key?("errors") ? DEFAULT_BAD_REQUEST_STATUS : fallback + rescue JSON::ParserError + fallback + end + + # Simple value object so callers can use either the convenience + # constructors above (success / bad_request / options) or the raw + # `build` factory with explicit status. + Response = Struct.new(:status, :body, :headers) do + def to_h + h = {statusCode: status, headers: headers} + h[:body] = body unless body.nil? + h + end + end + + def self.new(status:, body:, headers:) + Response.new(status, body, headers) + end + private_class_method :new + end + end +end diff --git a/lib/interscript-api/lambda_function.rb b/lib/interscript-api/lambda_function.rb index f9821c7..1d8f26c 100644 --- a/lib/interscript-api/lambda_function.rb +++ b/lib/interscript-api/lambda_function.rb @@ -1,58 +1,54 @@ +# frozen_string_literal: true + require "json" require_relative "graphql/schema" +require_relative "lambda/cors" +require_relative "lambda/request_parser" +require_relative "lambda/response_builder" require "bundler/setup" -def handler(event:, context: {}) - cors_origin = ENV["DEFAULT_ORIGIN"] - input_origin = event.fetch('headers', {}).fetch('origin', "") - - if /#{ENV["CORS_ORIGIN_REGEX"]}/ =~ input_origin - puts 'match origin' - cors_origin = input_origin - end - - headers = { - "Access-Control-Allow-Origin" => cors_origin, - "Access-Control-Allow-Headers"=> 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token', - "Access-Control-Allow-Methods"=> "POST, OPTIONS" - } - - body = event['body'] - if "OPTIONS".casecmp?(event['httpMethod']) || body.nil? || body.empty? - return { - statusCode: 200, - headers: headers, - } - end - - query = begin - JSON.parse(body)["query"] - rescue JSON::ParserError - body - end - - status_code = begin - result = InterscriptApi::Schema.execute(query).to_json - 200 - rescue StandardError => e - result = e.message - puts result - 400 - rescue => e - puts e - puts "Catch Error" - end - - begin - result_json = JSON.parse result - status_code = 400 if result_json.key?("errors") - rescue JSON::ParserError - #ignore +# AWS Lambda entry point. Thin orchestrator: parses the request, dispatches +# to the GraphQL schema, shapes the response. Each concern lives in its +# own module so the orchestration here stays readable and individually +# testable. +module InterscriptApi + module Lambda + module_function + + def handler(event:, context: {}) + headers = Cors.headers_for(event: event) + + return ResponseBuilder.options(headers: headers).to_h if options_request?(event) + + query = RequestParser.extract_query(body: event["body"]) + return ResponseBuilder.options(headers: headers).to_h if query.nil? + + result, status = execute(query) + status = ResponseBuilder.status_for(result_body: result, fallback: status) + + ResponseBuilder.build(status: status, body: result, headers: headers) + end + + # Runs the query through the schema. Returns [result_string, status_code]. + def execute(query) + [InterscriptApi::GraphQL::Schema.execute(query).to_json, ResponseBuilder::DEFAULT_OK_STATUS] + rescue InterscriptApi::Error => e + warn(e.message) + [e.message, ResponseBuilder::DEFAULT_BAD_REQUEST_STATUS] + rescue StandardError => e + warn(e.message) + warn(e.backtrace.first(5).join("\n")) if e.backtrace + [e.message, ResponseBuilder::DEFAULT_BAD_REQUEST_STATUS] + end + + def options_request?(event) + event["httpMethod"] == "OPTIONS" + end end +end - { - statusCode: status_code, - headers: headers, - body: result - } +# Backwards-compatible top-level entry point. Existing Lambda configurations +# reference `lib/interscript-api/lambda_function.handler`. +def handler(event:, context: {}) + InterscriptApi::Lambda.handler(event: event, context: context) end diff --git a/lib/limits.rb b/lib/limits.rb index 34e9a05..db3ed2f 100644 --- a/lib/limits.rb +++ b/lib/limits.rb @@ -2,7 +2,7 @@ module InterscriptApi LIMITS = { # timeout_millis: 10, - #1k input length - input_max_size: 1_000_000, + # 1k input length + input_max_size: 1_000_000 }.freeze end diff --git a/spec/interscript-api/lambda/modules_spec.rb b/spec/interscript-api/lambda/modules_spec.rb new file mode 100644 index 0000000..d83fbce --- /dev/null +++ b/spec/interscript-api/lambda/modules_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../../lib/interscript-api/lambda/cors" +require_relative "../../lib/interscript-api/lambda/request_parser" +require_relative "../../lib/interscript-api/lambda/response_builder" + +RSpec.describe InterscriptApi::Lambda::Cors do + describe ".headers_for" do + after { ENV.delete("TEST_CORS_REGEX") } + + it "returns default headers when no regex configured" do + ENV["DEFAULT_ORIGIN"] = "*" + ENV["CORS_ORIGIN_REGEX"] = "" + headers = described_class.headers_for(event: {"headers" => {"origin" => "https://example.com"}}) + expect(headers["Access-Control-Allow-Origin"]).to eq("*") + expect(headers["Access-Control-Allow-Methods"]).to eq("POST, OPTIONS") + end + + it "reflects origin when it matches the regex" do + ENV["CORS_ORIGIN_REGEX"] = "example\\.com" + headers = described_class.headers_for(event: {"headers" => {"origin" => "https://example.com"}}) + expect(headers["Access-Control-Allow-Origin"]).to eq("https://example.com") + end + + it "falls back to default when origin does not match" do + ENV["DEFAULT_ORIGIN"] = "https://default.test" + ENV["CORS_ORIGIN_REGEX"] = "example\\.com" + headers = described_class.headers_for(event: {"headers" => {"origin" => "https://other.test"}}) + expect(headers["Access-Control-Allow-Origin"]).to eq("https://default.test") + end + + it "handles missing headers in event" do + ENV["DEFAULT_ORIGIN"] = "*" + ENV["CORS_ORIGIN_REGEX"] = "" + headers = described_class.headers_for(event: {}) + expect(headers["Access-Control-Allow-Origin"]).to eq("*") + end + end +end + +RSpec.describe InterscriptApi::Lambda::RequestParser do + describe ".extract_query" do + it "returns nil for nil body" do + expect(described_class.extract_query(body: nil)).to be_nil + end + + it "returns nil for empty body" do + expect(described_class.extract_query(body: "")).to be_nil + end + + it "extracts query from JSON envelope" do + body = JSON.generate(query: "{ transliterate(systemCode: \"x\", input: \"y\") }") + expect(described_class.extract_query(body: body)) + .to eq("{ transliterate(systemCode: \"x\", input: \"y\") }") + end + + it "treats non-JSON body as raw query" do + expect(described_class.extract_query(body: "{ raw graphql }")) + .to eq("{ raw graphql }") + end + + it "returns raw string when JSON is not an object" do + expect(described_class.extract_query(body: "[1, 2, 3]")).to eq("[1, 2, 3]") + end + end +end + +RSpec.describe InterscriptApi::Lambda::ResponseBuilder do + describe ".success / .bad_request / .options" do + it "success has 200 status" do + r = described_class.success(body: "{}", headers: {}) + expect(r.status).to eq(200) + expect(r.body).to eq("{}") + end + + it "bad_request has 400 status" do + r = described_class.bad_request(body: "err", headers: {}) + expect(r.status).to eq(400) + end + + it "options omits body in to_h" do + r = described_class.options(headers: {"X" => "y"}) + expect(r.to_h).to eq(statusCode: 200, headers: {"X" => "y"}) + end + end + + describe ".status_for" do + it "returns 400 when body has errors key" do + body = JSON.generate(errors: [{"message" => "boom"}]) + expect(described_class.status_for(result_body: body, fallback: 200)).to eq(400) + end + + it "returns fallback when body is non-error JSON" do + body = JSON.generate(data: {"x" => 1}) + expect(described_class.status_for(result_body: body, fallback: 200)).to eq(200) + end + + it "returns fallback when body is not parseable JSON" do + expect(described_class.status_for(result_body: "Server Error", fallback: 200)).to eq(200) + end + end +end diff --git a/spec/interscript-api/lambda_function_spec.rb b/spec/interscript-api/lambda_function_spec.rb index 88e1260..a7ecedf 100644 --- a/spec/interscript-api/lambda_function_spec.rb +++ b/spec/interscript-api/lambda_function_spec.rb @@ -2,9 +2,7 @@ require "json" describe InterscriptApi do - context "With valid input" do - it "should return valid data when transliterate is called" do event = { "body" => '{transliterate(systemCode: "odni-rus-Cyrl-Latn-2015", input: "Михаил Тимофеевич Калашников")}' @@ -34,9 +32,9 @@ end it "should print system_codes" do - ENV['INTERSCRIPT_STAGING'] = '1' + ENV["INTERSCRIPT_STAGING"] = "1" event = { - "body" => '{systemCodes}' + "body" => "{systemCodes}" } rs = handler(event: event) rs = JSON.parse(rs[:body])["data"].to_h @@ -59,7 +57,6 @@ rs = /#{cors_origin}/ =~ "https://localhost:3001" expect(rs).to be_truthy - end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 251aa51..a403ac5 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -44,57 +44,55 @@ # triggering implicit auto-inclusion in groups with matching metadata. config.shared_context_metadata_behavior = :apply_to_host_groups -# The settings below are suggested to provide a good initial experience -# with RSpec, but feel free to customize to your heart's content. -=begin - # This allows you to limit a spec run to individual examples or groups - # you care about by tagging them with `:focus` metadata. When nothing - # is tagged with `:focus`, all examples get run. RSpec also provides - # aliases for `it`, `describe`, and `context` that include `:focus` - # metadata: `fit`, `fdescribe` and `fcontext`, respectively. - config.filter_run_when_matching :focus - - # Allows RSpec to persist some state between runs in order to support - # the `--only-failures` and `--next-failure` CLI options. We recommend - # you configure your source control system to ignore this file. - config.example_status_persistence_file_path = "spec/examples.txt" - - # Limits the available syntax to the non-monkey patched syntax that is - # recommended. For more details, see: - # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ - # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ - # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode - config.disable_monkey_patching! - - # This setting enables warnings. It's recommended, but in some cases may - # be too noisy due to issues in dependencies. - config.warnings = true - - # Many RSpec users commonly either run the entire suite or an individual - # file, and it's useful to allow more verbose output when running an - # individual spec file. - if config.files_to_run.one? - # Use the documentation formatter for detailed output, - # unless a formatter has already been configured - # (e.g. via a command-line flag). - config.default_formatter = "doc" - end - - # Print the 10 slowest examples and example groups at the - # end of the spec run, to help surface which specs are running - # particularly slow. - config.profile_examples = 10 - - # Run specs in random order to surface order dependencies. If you find an - # order dependency and want to debug it, you can fix the order by providing - # the seed, which is printed after each run. - # --seed 1234 - config.order = :random - - # Seed global randomization in this process using the `--seed` CLI option. - # Setting this allows you to use `--seed` to deterministically reproduce - # test failures related to randomization by passing the same `--seed` value - # as the one that triggered the failure. - Kernel.srand config.seed -=end + # The settings below are suggested to provide a good initial experience + # with RSpec, but feel free to customize to your heart's content. + # # This allows you to limit a spec run to individual examples or groups + # # you care about by tagging them with `:focus` metadata. When nothing + # # is tagged with `:focus`, all examples get run. RSpec also provides + # # aliases for `it`, `describe`, and `context` that include `:focus` + # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + # config.filter_run_when_matching :focus + # + # # Allows RSpec to persist some state between runs in order to support + # # the `--only-failures` and `--next-failure` CLI options. We recommend + # # you configure your source control system to ignore this file. + # config.example_status_persistence_file_path = "spec/examples.txt" + # + # # Limits the available syntax to the non-monkey patched syntax that is + # # recommended. For more details, see: + # # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ + # # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ + # # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode + # config.disable_monkey_patching! + # + # # This setting enables warnings. It's recommended, but in some cases may + # # be too noisy due to issues in dependencies. + # config.warnings = true + # + # # Many RSpec users commonly either run the entire suite or an individual + # # file, and it's useful to allow more verbose output when running an + # # individual spec file. + # if config.files_to_run.one? + # # Use the documentation formatter for detailed output, + # # unless a formatter has already been configured + # # (e.g. via a command-line flag). + # config.default_formatter = "doc" + # end + # + # # Print the 10 slowest examples and example groups at the + # # end of the spec run, to help surface which specs are running + # # particularly slow. + # config.profile_examples = 10 + # + # # Run specs in random order to surface order dependencies. If you find an + # # order dependency and want to debug it, you can fix the order by providing + # # the seed, which is printed after each run. + # # --seed 1234 + # config.order = :random + # + # # Seed global randomization in this process using the `--seed` CLI option. + # # Setting this allows you to use `--seed` to deterministically reproduce + # # test failures related to randomization by passing the same `--seed` value + # # as the one that triggered the failure. + # Kernel.srand config.seed end