{"id":20836556,"url":"https://github.com/kenichi/angelo","last_synced_at":"2025-05-16T18:08:26.207Z","repository":{"id":11473381,"uuid":"13941428","full_name":"kenichi/angelo","owner":"kenichi","description":"Sinatra-like DSL for Reel that supports WebSockets and SSE","archived":false,"fork":false,"pushed_at":"2022-03-23T16:56:03.000Z","size":754,"stargazers_count":301,"open_issues_count":15,"forks_count":22,"subscribers_count":16,"default_branch":"master","last_synced_at":"2025-04-12T16:58:47.946Z","etag":null,"topics":["realtime","ruby","server-sent-events","websockets"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/kenichi.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":"code_of_conduct.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2013-10-28T23:21:56.000Z","updated_at":"2024-12-10T10:12:01.000Z","dependencies_parsed_at":"2022-09-01T09:03:03.974Z","dependency_job_id":null,"html_url":"https://github.com/kenichi/angelo","commit_stats":null,"previous_names":[],"tags_count":44,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kenichi%2Fangelo","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kenichi%2Fangelo/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kenichi%2Fangelo/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kenichi%2Fangelo/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/kenichi","download_url":"https://codeload.github.com/kenichi/angelo/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":254582907,"owners_count":22095518,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["realtime","ruby","server-sent-events","websockets"],"created_at":"2024-11-18T00:30:40.811Z","updated_at":"2025-05-16T18:08:26.169Z","avatar_url":"https://github.com/kenichi.png","language":"Ruby","readme":"Angelo\n======\n\n[![Build Status](https://travis-ci.org/kenichi/angelo.png?branch=master)](https://travis-ci.org/kenichi/angelo)\n\nA [Sinatra](https://github.com/sinatra/sinatra)-like DSL for [Reel](https://github.com/celluloid/reel).\n\n### tl;dr\n\n* websocket support via `websocket('/path'){|s| ... }` route builder\n* SSE support via `eventsource('/path'){|s| ... }` route builder\n* contextual websocket/sse stashing via `websockets` and `sses` helpers\n* `task` handling via `async` and `future` helpers\n* no rack\n* erb, haml, and markdown support\n* mustermann support\n\n### What is Angelo?\n\nJust like Sinatra, Angelo gives you an expressive DSL for creating web applications. There are some\nnotable differences, but the basics remain the same: you can either create a \"classic\" application\nby requiring 'angelo/main' and using the DSL at the top level of your script, or a \"modular\"\napplication by requiring 'angelo', subclassing `Angelo::Base`, and calling `.run!` on that class for the\nservice to start.\nIn addition, and perhaps more importantly, **Angelo is built on Reel, which is built on\nCelluloid::IO and gives you a reactor with evented IO in Ruby!**\n\nThings will feel very familiar to anyone experienced with Sinatra. You can define\nroute handlers denoted by HTTP verb and path with parameters set from path matching (using\n[Mustermann](#mustermann)), the query string, and post body.\nA route block may return:\n\n* The body of the response in full as a `String`.\n* A `Hash` (or anything that `respond_to? :to_json`) if the content type is set to `:json`.\n* Any object that responds to `#each(\u0026block)` if the transfer encoding is set to `:chunked`.\nThere is also a `chunked_response` helper that will take a block, set the transfer encoding, and return\nan appropriate object.\n\nAngelo also features `before` and `after` filter blocks, just like Sinatra. Filters are ordered as defined,\nand called in that order. When defined without a path, they run for all matched requests. With a path,\nthe path is interpreted as a Mustermann pattern and params are merged. `before` filters can set instance\nvariables which can be used in the route block and the `after` filter.\nFor more info on the difference in how after blocks are handled, see the Errors section below for more info.\n\n### Websockets!\n\nOne of the main motivations for Angelo was the ability to define websocket handlers with ease. Through\nthe addition of a `websocket` route builder and a `websockets` helper, Angelo attempts to make it easy\nfor you to build real-time web applications.\n\n##### Route Builder\n\nThe `websocket` route builder accepts a path and a block, and passes the actual websocket to the block\nas the only argument. This socket is an instance of Reel's\n[WebSocket](https://github.com/celluloid/reel/blob/master/lib/reel/websocket.rb) class, and, as such,\nresponds to methods like `on_message` and `on_close`. A service-wide `on_pong` handler may be defined\nto customize the behavior when a pong frame comes back from a connected websocket client.\n\n##### `websockets` helper\n\nAngelo includes a \"stash\" helper for connected websockets. One can `\u003c\u003c` a websocket into `websockets`\nfrom inside a websocket handler block. These can \"later\" be iterated over so one can do things like\nemit a message on every connected websocket when the service receives a POST request.\n\nThe `websockets` helper also includes a context ability, so you can stash connected websocket clients\ninto different \"sections\". Also, by default, the helper will `reject!` any closed sockets before\nreturning; you may optionally pass `false` to the helper to skip this step.\n\n##### Example!\n\nHere is an example of the `websocket` route builder, the `websockets` helper, and the context feature:\n\n```ruby\nrequire 'angelo'\n\nclass Foo \u003c Angelo::Base\n\n  websocket '/' do |ws|\n    websockets \u003c\u003c ws\n  end\n\n  websocket '/bar' do |ws|\n    websockets[:bar] \u003c\u003c ws\n  end\n\n  post '/' do\n    websockets.each {|ws| ws.write params[:foo]}\n  end\n\n  post '/bar' do\n    websockets[:bar].each {|ws| ws.write params[:bar]}\n  end\n\nend\n\nFoo.run!\n```\n\nIn this case, any clients that connect to a websocket at the path '/' will be stashed in the\ndefault websockets array; clients that connect to '/bar' will be stashed in the `:bar` section.\n\nEach \"section\" is accessed with a familiar, `Hash`-like syntax, and can be iterated over with\na `.each` block.\n\nWhen a `POST /` with a 'foo' param is received, any value is messaged out to all '/' connected\nwebsockets. When a `POST /bar` with a 'bar' param is received, any value is messaged out to all\nwebsockets connected to '/bar'.\n\n### SSE - Server-Sent Events\n\nThe `eventsource` route builder also accepts a path and a block, and passes the socket to the block,\njust like the `websocket` builder. This socket is actually the raw `Celluloid::IO::TCPSocket` and is\n\"detached\" from the regular handling. There are no \"on-*\" methods; the `write` method should suffice.\nTo make it easier to deal with creation of the properly formatted Strings to send, Angelo provides\na couple helpers.\n\n##### `sse_event` helper\n\nTo create an \"event\" that a javascript EventListener on the client can respond to:\n\n```ruby\neventsource '/sse' do |s|\n  event = sse_event :foo, some_key: 'blah', other_key: 'boo'\n  s.write event\n  s.close\nend\n```\n\nIn this case, the EventListener would have to be configured to listen for the `foo` event:\n\n```javascript\nvar sse = new EventSource('/sse');\nsse.addEventListener('foo', function(e){ console.log(\"got foo event!\\n\" + JSON.parse(e.data)); });\n```\n\nThe `sse_event` helper accepts a normal `String` for the data, but will automatically convert a `Hash`\nargument to a JSON object.\n\nNOTE: there is a shortcut helper on the actual SSE object itself:\n\n```ruby\neventsource '/sse' do |sse|\n  sse.event :foo, some_key: 'blah', other_key: 'boo'\n  sse.event :close\nend\n```\n\n##### `sse_message` helper\n\nThe `sse_message` helper behaves exactly the same as `sse_event`, but does not take an event name:\n\n```ruby\neventsource '/sse' do |s|\n  msg = sse_message some_key: 'blah', other_key: 'boo'\n  s.write msg\n  s.close\nend\n```\n\nThe client javascript would need to be altered to use the `EventSource.onmessage` property as well:\n\n```javascript\nvar sse = new EventSource('/sse');\nsse.onmessage = function(e){ console.log(\"got message!\\n\" + JSON.parse(e.data)); };\n```\n\nNOTE: there is a shortcut helper on the actual SSE object itself:\n\n```ruby\neventsource '/sse' do |sse|\n  sse.message some_key: 'blah', other_key: 'boo'\n  sse.event :close\nend\n```\n\n##### `sses` helper\n\nAngelo also includes a \"stash\" helper for SSE connections. One can `\u003c\u003c` a socket into `sses` from\ninside an `eventsource` handler block. These can \"later\" be iterated over so one can do things\nlike emit a message on every SSE connection when the service receives a POST request.\n\nThe `sses` helper includes the same context ability as the `websockets` helper. Also, by default,\nthe helper will `reject!` any closed sockets before returning, just like `websockets`. You may\noptionally pass `false` to the helper to skip this step. In addition, the `sses` stash includes\nmethods for easily sending events or messages to all stashed connections. **Note that the\n`Stash::SSE#event` method only works on non-default contexts and uses the context name as the event\nname.**\n\n```ruby\neventsource '/sse' do |s|\n  sses[:foo] \u003c\u003c s\nend\n\npost '/sse_message' do\n  sses[:foo].message params[:data]\nend\n\npost '/sse_event' do\n  sses[:foo].event params[:data]\nend\n```\n\n##### `eventsource` instance helper\n\nAdditionally, you can also start SSE handling *conditionally* from inside a GET block:\n\n```ruby\nget '/sse_maybe' do\n  if params[:sse]\n    eventsource do |c|\n      sses \u003c\u003c c\n      c.write sse_message 'wooo fancy SSE for you!'\n    end\n  else\n    'boring regular old get response'\n  end\nend\n\npost '/sse_event' do\n  sses.each {|sse| sse.write sse_event(:foo, 'fancy sse event!')}\nend\n```\n\nHandling this on the client may require conditionals for [browsers](http://caniuse.com/eventsource) that\ndo not support EventSource yet, since this will respond with a non-\"text/event-stream\" Content-Type if\n'sse' is not present in the params.\n\n##### `EventSource#on_close` helper\n\nWhen inside an eventsource block, you may want to do something specific when a client closes the\nconnection. For this case, there are `on_close` and `on_close=` methods on the object passed to the block\nthat will get called if the client closes the socket. The assignment method takes a proc object and the\nother one takes a block:\n\n```ruby\nget '/' do\n  eventsource do |es|\n\n    # assignment!\n    es.on_close = -\u003e{sses(false).remove_socket es}\n\n    sses \u003c\u003c es\n  end\nend\n\neventsource '/sse' do |es|\n\n  # just passing a block here\n  es.on_close {sses(false).remove_socket es}\n\n  sses \u003c\u003c es\nend\n```\n\nNote the use of the optional parameter of the stashes here; by default, stash accessors (`websockets` and\n`sses`) will `reject!` any closed sockets before letting you in. If you pass `false` to the stash\naccessors, they will skip the `reject!` step.\n\n### Tasks + Async / Future\n\nAngelo is built on Reel and Celluloid::IO, giving your web application the ability to define\n\"tasks\" and call them from route handler blocks in an `async` or `future` style.\n\n##### `task` builder\n\nYou can define a task on the reactor using the `task` class method and giving it a symbol and a\nblock. The block can take arguments that you can pass later, with `async` or `future`.\n\n```ruby\n# defining a task on the reactor called `:in_sec` which will sleep for\n# the given number of seconds, then return the given message.\n#\ntask :in_sec do |sec, msg|\n  sleep sec.to_i\n  msg\nend\n```\n\n##### `async` helper\n\nThis helper is directly analogous to the Celluoid method of the same name. Once tasks are defined,\nyou can call them with this helper method, passing the symbol of the task name and any arguments.\nThe task will run on the reactor, asynchronously, and return immediately.\n\n```ruby\nget '/' do\n  # run the task defined above asynchronously, return immediately\n  #\n  async :in_sec, params[:sec], params[:msg]\n\n  # NOTE: params[:msg] is discarded, the return value of tasks called with `async` is nil.\n\n  # return this response body while the task is still running\n  # assuming params[:sec] is \u003e 0\n  #\n  'hi'\nend\n```\n\n##### `future` helper\n\nJust like `async`, this comes from Celluloid as well. It behaves exactly like `async`, with the\nnotable exception of returning a \"future\" object that you can call `#value` on later to retrieve\nthe return value of the task. Calling `#value` will \"block\" until the task is finished, while the\nreactor continues to process requests.\n\n```ruby\nget '/' do\n  # run the task defined above asynchronously, return immediately\n  #\n  f = future :in_sec, params[:sec], params[:msg]\n\n  # now, block until the task is finished and return the task's value\n  # as a response body\n  #\n  f.value\nend\n```\n\n### Errors and Halting\n\nAngelo gives you two ordained methods of stopping route processing:\n\n* raise an instance of `RequestError`\n* `halt` with a status code and message\n\nThe main difference is that `halt` will still run `after` blocks, and raising `RequestError`\nwill bypass `after` blocks.\n\nAny other exceptions or errors raised by your route handler will be handled with a 500 status\ncode and the message will be the body of the response.\n\n#### RequestError\n\nRaising an instance of `Angelo::RequestError` causes a 400 status code response, and the message\nin the instance is the body of the the response. If the route or class was set to respond with\nJSON, the body is converted to a JSON object with one key, `error`, that has the value of the message.\nIf the message is a `Hash`, the hash is converted to a JSON object, or to a string for other content\ntypes.\n\nIf you want to return a different status code, you can pass it as a second argument to\n`RequestError.new`. See example below.\n\n#### Halting\n\nYou can `halt` from within any route handler, optionally passing status code and a body. The\nbody is handled the same way as raising `RequestError`.\n\n##### Example\n\n```ruby\nget '/' do\n  raise RequestError.new '\"foo\" is a required parameter' unless params[:foo]\n  params[:foo]\nend\n\nget '/json' do\n  content_type :json\n  raise RequestError.new foo: \"required!\"\n  {foo: params[:foo]}\nend\n\nget '/not_found' do\n  raise RequestError.new 'not found', 404\nend\n\nget '/halt' do\n  halt 200, \"everything's fine\"\n  raise RequestError.new \"won't get here\"\nend\n```\n\n```\n$ curl -i http://127.0.0.1:4567/\nHTTP/1.1 400 Bad Request\nContent-Type: text/html\nConnection: Keep-Alive\nContent-Length: 29\n\n\"foo\" is a required parameter\n\n$ curl -i http://127.0.0.1:4567/?foo=bar\nHTTP/1.1 200 OK\nContent-Type: text/html\nConnection: Keep-Alive\nContent-Length: 3\n\nbar\n\n$ curl -i http://127.0.0.1:4567/json\nHTTP/1.1 400 Bad Request\nContent-Type: application/json\nConnection: Keep-Alive\nContent-Length: 29\n\n{\"error\":{\"foo\":\"required!\"}}\n\n$ curl -i http://127.0.0.1:4567/not_found\nHTTP/1.1 404 Not Found\nContent-Type: text/html\nConnection: Keep-Alive\nContent-Length: 9\n\nnot found\n\n$ curl -i http://127.0.0.1:4567/halt\nHTTP/1.1 200 OK\nContent-Type: text/html\nConnection: Keep-Alive\nContent-Length: 18\n\neverything's fine\n```\n\n### [Tilt](https://github.com/rtomayko/tilt) / ERB\n\n```ruby\nclass Foo \u003c Angelo::Base\n\n  views_dir 'some/other/path' # defaults to './views'\n\n  get '/' do\n    erb :index\n  end\n\nend\n```\n\nThe Angleo::Tilt::ERB module and the `erb` method do some extra work for you:\n\n* templates are pre-compiled, sorted by type.\n* template type is determined by word between name and .erb (ex: `index.html.erb`\n  is `:index` name and `:html` type)\n* the template chosen to render is determined based on:\n    * `:type` option passed to `erb` helper\n    * `Accept` request header value\n    * `Content-Type` response header value\n    * default to `:html`\n\nSee [views](https://github.com/kenichi/angelo/tree/master/test/test_app_root/views) for examples.\n\n### [Mustermann](https://github.com/rkh/mustermann)\n\n```ruby\nclass Foo \u003c Angelo::Base\n\n  get '/:foo/things/:bar' do\n\n    # `params` is merged with the Mustermann object#params hash, so\n    # a \"GET /some/things/are_good?foo=other\u0026bar=are_bad\" would have:\n    #   params: {\n    #     'foo' =\u003e 'some',\n    #     'bar' =\u003e 'are_good'\n    #   }\n\n    @foo = params[:foo]\n    @bar = params[:bar]\n    erb :index\n  end\n\n  before '/:fu/things/*' do\n\n    # `params` is merged with the Mustermann object#params hash, as\n    # parsed with the current request path against this before block's\n    # pattern. in the route handler, `params[:fu]` is no longer available.\n\n    @fu = params[:fu]\n  end\n\nend\n```\n\n### Classic vs. modular apps\n\nLike Sinatra, Angelo apps can be written in either \"classic\" style or\nso-called \"modular\" style.  Which style you use is more of a personal\npreference than anything else.\n\nA classic-style app requires \"angelo/main\" and defines the app\ndirectly at the top level using the DSL.  In addition, classic apps:\n\n* Can use a `helpers` block to define methods that can be called from\nfilters and route handlers. The `helpers` method can also include methods\nfrom one or more modules passed as arguments instead of or in addition\nto taking a block.\n* Parse optional command-line options \"-o addr\" and \"-p port\" to set\nthe bind address and listen port, respectively.\n* Are run automatically.\n\n*Note: unlike Sinatra, define a classic app by requiring \"angelo/main\"\nand a modular app by requiring \"angelo\".  Sinatra uses \"sinatra\" and\n\"sinatra/base\" to do the same things.*\n\nHere's a classic app:\n\n```ruby\nrequire 'angelo/main'\n\nhelpers do\n  def say_hello\n    \"Hello\"\n  end\nend\n\nget \"/hello\" do\n  \"#{say_hello} to you, too.\"\nend\n```\n\nAnd the same app in modular style:\n\n```ruby\nrequire 'angelo'\n\nclass HelloApp \u003c Angelo::Base\n  def say_hello\n    \"Hello\"\n  end\n\n  get \"/hello\" do\n    \"#{say_hello} to you, too.\"\n  end\nend\n\nHelloApp.run!\n```\n\n### JSON HTTP API\n\nIf you post JSON data with a JSON Content-Type, angelo will:\n\n* merge objects into the `params` SymHash\n* parse arrays and make them available via `request_body`\n\nN.B. `request_body` is functionally equivalent to `request.body.to_s` otherwise.\n\nIf your `content_type` is set to `:json`, angelo will convert:\n\n* anything returned from a route block that `respond_to? :to_json`\n* `RequestError` message data\n* `halt` data\n\n### Documentation\n\n**I'm bad at documentation and I feel bad.**\n\nOthers have helped, and there is a YaRD plugin for Angelo [here](https://github.com/artcom/yard-angelo)\nif you would like to document your apps built with Angelo. (thanks: @katjaeinsfeld, @artcom)\n\n### WORK LEFT TO DO\n\nLots of work left to do!\n\n### Full-ish example\n\n```ruby\nrequire 'angelo'\n\nclass Foo \u003c Angelo::Base\n\n  # just some constants to use in routes later...\n  #\n  TEST = {foo: \"bar\", baz: 123, bat: false}.to_json\n  HEART = '\u003c3'\n\n  # a flag to know if the :heart task is running\n  #\n  @@hearting = false\n\n  # you can define instance methods, just like Sinatra!\n  #\n  def pong; 'pong'; end\n  def foo; params[:foo]; end\n\n  # standard HTTP GET handler\n  #\n  get '/ping' do\n    pong\n  end\n\n  # standard HTTP POST handler\n  #\n  post '/foo' do\n    foo\n  end\n\n  post '/bar' do\n    params.to_json\n  end\n\n  # emit the TEST JSON value on all :emit_test websockets\n  # return the params posted as JSON\n  #\n  post '/emit' do\n    websockets[:emit_test].each {|ws| ws.write TEST}\n    params.to_json\n  end\n\n  # handle websocket requests at '/ws'\n  # stash them in the :emit_test context\n  # write 6 messages to the websocket whenever a message is received\n  #\n  websocket '/ws' do |ws|\n    websockets[:emit_test] \u003c\u003c ws\n    ws.on_message do |msg|\n      5.times { ws.write TEST }\n      ws.write foo.to_json\n    end\n  end\n\n  # emit the TEST JSON value on all :other websockets\n  #\n  post '/other' do\n    websockets[:other].each {|ws| ws.write TEST}\n    ''\n  end\n\n  # stash '/other/ws' connected websockets in the :other context\n  #\n  websocket '/other/ws' do |ws|\n    websockets[:other] \u003c\u003c ws\n  end\n\n  websocket '/hearts' do |ws|\n\n    # this is a call to Base#async, actually calling\n    # the reactor to start the task\n    #\n    async :hearts unless @@hearting\n\n    websockets[:hearts] \u003c\u003c ws\n  end\n\n  # this is a call to Base.task, defining the task\n  # to perform on the reactor\n  #\n  task :hearts do\n    @@hearting = true\n    every(10){ websockets[:hearts].each {|ws| ws.write HEART } }\n  end\n\n  post '/in/:sec/sec/:msg' do\n\n    # this is a call to Base#future, telling the reactor\n    # do this thing and we'll want the value eventually\n    #\n    f = future :in_sec, params[:sec], params[:msg]\n    f.value\n  end\n\n  # define a task on the reactor that sleeps for the given number of\n  # seconds and returns the given message\n  #\n  task :in_sec do |sec, msg|\n    sleep sec.to_i\n    msg\n  end\n\n  # return a chunked response of JSON for 5 seconds\n  #\n  get '/chunky_json' do\n    content_type :json\n\n    # this helper requires a block that takes one arg, the response\n    # proc to call with each chunk (i.e. the block that is passed to\n    # `#each`)\n    #\n    chunked_response do |response|\n      5.times do\n        response.call time: Time.now.to_i\n        sleep 1\n      end\n    end\n  end\n\nend\n\nFoo.run!\n```\n\n### Contributing\n\nAnyone is welcome to contribute. Conduct is guided by the [Contributor Covenant](http://contributor-covenant.org).\nSee `code_of_conduct.md`.\n\nTo contribute to Angelo, please:\n\n* fork the repository to your GitHub account\n* create a branch for the feature or fix\n* commit your changes to that branch, please include tests if applicable\n* submit a Pull Request back to the main repository's `master` branch\n\nAfter review and acceptance, your changes will be merged and noted in `CHANGLOG.md`.\n\n### Testing\n\nUnit tests are done with Minitest. Run them with :\n\n```\nbundle install\nrake test\n```\n\n### License\n\n[Apache 2.0](LICENSE)\n\n### Name\n\nWhy the name \"Angelo\"? Since the project mimics Sinatra's DSL, I thought it best to keep a reference to\nThe Chairman in the name. It turns out that Frank Sinatra won an Academy Award for his role 'Angelo\nMaggio' in 'From Here to Eternity'. I appropriated the name since this is like Sinatra on Reel (film).\n","funding_links":[],"categories":["Micro Frameworks inspired by Sinatra","Web Servers"],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkenichi%2Fangelo","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fkenichi%2Fangelo","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkenichi%2Fangelo/lists"}