camblan
    • Create new note
    • Create a note from template
      • Sharing URL Link copied
      • /edit
      • View mode
        • Edit mode
        • View mode
        • Book mode
        • Slide mode
        Edit mode View mode Book mode Slide mode
      • Customize slides
      • Note Permission
      • Read
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
      • Write
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
      • Engagement control Commenting, Suggest edit, Emoji Reply
    • Invite by email
      Invitee

      This note has no invitees

    • Publish Note

      Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note

      Your note will be visible on your profile and discoverable by anyone.
      Your note is now live.
      This note is visible on your profile and discoverable online.
      Everyone on the web can find and read all notes of this public team.
      See published notes
      Unpublish note
      Please check the box to agree to the Community Guidelines.
      View profile
    • Commenting
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
      • Everyone
    • Suggest edit
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
    • Emoji Reply
    • Enable
    • Versions and GitHub Sync
    • Note settings
    • Note Insights New
    • Engagement control
    • Make a copy
    • Transfer ownership
    • Delete this note
    • Save as template
    • Insert from template
    • Import from
      • Dropbox
      • Google Drive
      • Gist
      • Clipboard
    • Export to
      • Dropbox
      • Google Drive
      • Gist
    • Download
      • Markdown
      • HTML
      • Raw HTML
Menu Note settings Note Insights Versions and GitHub Sync Sharing URL Create Help
Create Create new note Create a note from template
Menu
Options
Engagement control Make a copy Transfer ownership Delete this note
Import from
Dropbox Google Drive Gist Clipboard
Export to
Dropbox Google Drive Gist
Download
Markdown HTML Raw HTML
Back
Sharing URL Link copied
/edit
View mode
  • Edit mode
  • View mode
  • Book mode
  • Slide mode
Edit mode View mode Book mode Slide mode
Customize slides
Note Permission
Read
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
Write
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
Engagement control Commenting, Suggest edit, Emoji Reply
  • Invite by email
    Invitee

    This note has no invitees

  • Publish Note

    Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note

    Your note will be visible on your profile and discoverable by anyone.
    Your note is now live.
    This note is visible on your profile and discoverable online.
    Everyone on the web can find and read all notes of this public team.
    See published notes
    Unpublish note
    Please check the box to agree to the Community Guidelines.
    View profile
    Engagement control
    Commenting
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    • Everyone
    Suggest edit
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    Emoji Reply
    Enable
    Import from Dropbox Google Drive Gist Clipboard
       Owned this note    Owned this note      
    Published Linked with GitHub
    • Any changes
      Be notified of any changes
    • Mention me
      Be notified of mention me
    • Unsubscribe
    # [FR] Workshop GraphQL-Ruby Chez [Tymate](https://tymate.com), la question de l'utilisation de [GraphQL](https://graphql.org/) pour nos API se pose depuis longtemps. La plupart de nos applications desktop étant conçues avec React, c'était évidemment une demande récurrente de nos dévelopeurs front-end. Je m'appelle [Julien](https://twitter.com/Sylfaen), j'ai 32 ans et je suis développeur Ruby depuis un peu plus d'un an, après une reconversion professionnelle. Cet article est une retranscription libre d'un workshop interne à Tymate au cours duquel j'ai présenté mon retour d'expérience sur une première utilisation de la gem [GraphQL Ruby](https://graphql-ruby.org). La suite présume une connaissance sommaire du fonctionnement de GraphQL. ## Structure des données La première chose à savoir en commençant à travailler avec GraphQL, c’est que tout est typé. Chaque donnée qu’on utilise ou qu’on veut sérialiser doit être clairement définie. Ce n'est pas une habitude courante dans une utilisation conventionnelle de Ruby. Mais cela s'avère rapidement très confortable. ### Types On va donc naturellement commencer par définir des `Types`. D’abord, on créera des `Types` pour les `models` de notre API dont on aura besoin pour nos requêtes GraphQL. La définition des `Types` GraphQL ressemble beaucoup à la logique qu’on retrouve dans les serializers sur nos API REST classiques, à savoir la définition des données qu’on pourra consulter, et la forme sous laquelle ces données sont mises à disposition, mais va évidemment plus loin en intégrant le typage, des validations, éventuellement une gestion de droits... Ci-dessous, un exemple de type GraphQL correspondant à un `model` de notre API : ```ruby module Types class ProviderType < Types::BaseObject # Ces lignes permettent d'intégrer les spécificités de Relay au type # on y reviendra plus bas dans l'article implements GraphQL::Relay::Node.interface global_id_field :id # Ici, on définit les champs qu'on va pouvoir requêter sur notre objet # il peut s'agir de champs en base, de méthodes issues du model # voire de méthodes directement définies dans le type. # Pour chaque champ, on doit spécifier le type # et la possibilité d'être nulle. field :display_name, String, null: true field :phone_number, String, null: true field :siret, String, null: true field :email, String, null: true field :iban, String, null: true field :bic, String, null: true field :address, Types::AddressType, null: true field :created_at, GraphQL::Types::ISO8601DateTime, null: true field :updated_at, GraphQL::Types::ISO8601DateTime, null: true # Ici, on choisit de réécrire la relation address du model. # La simple définition du field address suffit # pour retourner les bons objets. # Mais ce faisant, on peut intégrer la logique de # la gem BatchLoader pour éradiquer les N+1 de notre requête. def address BatchLoader::GraphQL.for(object).batch do |providers_ids, loader| Address.where( addressable_type: 'Provider', addressable_id: providers_ids ).each { |address| loader.call(object, address) } end end end end ``` ### Queries Une fois qu'on a défini les`Types` dont on a besoin, ils apparaissent certes directement dans la documentation générée automatiquement par [GraphiQL](https://github.com/graphql/graphiql/tree/master/packages/graphiql#readme) mais ils ne peuvent pas être directement consultés par les consommateurs de l'API. Pour cela, il est nécessaire de créer des queries. Il s'agit de l'équivalent d'une requête GET en REST. Le consommateur va demander à consulter soit un objet spécifique, soit une collection d'objets (qu'on appellera une `connection` dans le cadre de la méthologie Relay, j'y reviendrai). #### Afficher un objet isolé Les queries se définissent dans la classe `QueryType` : ```ruby module Types class QueryType < Types::BaseObject # Ici, le field correspond au nom de la query # les arguments passés dans le block permettront # de retrouver l'objet recherché. field :item, Types::ItemType, null: false do argument :id, ID, required: true end # Contrairement à la définition des types de models vue plus haut, # pour les queries le field ne se suffit pas à lui-même. # Il est nécessaire de définir dans une méthode du même nom # la logique qui va résoudre la query. def item(id:) Item.find(id) end end end ``` Dans son fonctionnement, une query est très proche de ce qu'on fait dans un controller REST. On définit la requête dans un *field*, auquel on associe le type du ou des objets qu'on va retourner, et des arguments qui vont permettre de récupérer l'objet voulu, par exemple un ID. Dans la méthode item, on récupère les arguments passés dans le field, et on va chercher l'objet correspondant. ##### Alléger ses queries Dès qu'on commence à avoir beaucoup de `models` différents qu'on veut pouvoir afficher dans une requête, on va être confronté à pas mal de duplications de code. Un peu de métaprogrammation permet d'alléger notre `query_type.rb` : ```ruby module Types class QueryType < Types::BaseObject # On définit d'abord une méthode commune qui retrouve un objet # à partir d'un ID unique fourni par Relay def item(id:) ApiSchema.object_from_id(id, context) end # Et on crée les fields qui appellent la méthode sus-définie pour tous # les types pour lesquels on a besoin d'une query %i[ something something_else attachment user identity drink food provider ].each do |method| field method, "Types::#{method.to_s.camelize}Type".constantize, null: false do argument :id, ID, required: true end alias_method method, :item end end end ``` #### Afficher une collection d'objets Par défaut, il n'y a pas grande différence entre une query qui retourne un objet et une autre qui retourne une collection d'objets. On va tout simplement devoir spécifier que le résultat de la requête sera un tableau en passant le type lui-même entre `[]` : ```ruby module Types class QueryType < Types::BaseObject field :items, [Types::ItemType], null: false do # on ne cherche plus un item par son ID, mais par son contenant argument :category_id, ID, required: true end def item(category_id:) Category.find(category_id).items end end end ``` On appelle cette query ainsi : ``` query items { items { id displayName } } ``` Et on obtient le json suivant en réponse : ```json [ { "id": "1", "displayName": "toto" }, { "id": "2", "displayName": "tata" } ] ``` ### Relay et les connections Cela peut fonctionner pour des besoins très basiques, mais ça va être vite très limité. Pour des requêtes mieux structurées, Facebook a créé Relay, un client GraphQL qui introduit deux paradigmes très pratiques (gérés nativement par la gem) : - on travaille avec des IDs globaux (des strings créés à partir d'un encodage en base64 de `["NomDuModel, ID"]`) - une nomenclature bien spécifique pour organiser et consommer les collections d'objets : les `connections` > *edit: J'ai réalisé le workshop initial en m'appuyant sur la version v1.9 de la gem graphql-ruby. La notion de `connection` a été extraite de Relay pour devenir le formatage par défaut des collections dans la v1.10.* L'ID global à la place des ID classiques est là avant toute chose pour les applications JS qui vont consommer l'API. Cela permet notamment de toujours utiliser cet ID comme clé dans les boucles d'objets. Du point de vue de l'API, travailler avec des ID uniques indépendamment du type d'objet est également très pratique. #### Nomenclature Pour ce qui est des `connections`, voici à quoi ressemble notre précédente query adaptée sous ce format : ``` { items(first: 2) { totalCount pageInfo { hasNextPage hasPreviousPage endCursor startCursor } edges { cursor node { id displayName } } } } ``` Et la réponse correspondante : ```json { "data": { "items": { "totalCount": 351, "pageInfo": { "hasNextPage": true, "hasPreviousPage": false, "endCursor": "Mw", "startCursor": "MQ" }, "edges": [ { "cursor": "MQ", "node": { "id": "UGxhY2UtMzUy", "displayName": "Redbeard" } }, { "cursor": "Mg", "node": { "id": "UGxhY2UtMzUx", "displayName": "Frey of Riverrun" } }, { "cursor": "Mw", "node": { "id": "QmlsbGVyLTI=", "displayName": "Something Else" } } ] } } } ``` La `connection` nous donne accès à des informations complémentaires en plus de notre collection. Par défaut, la gem nous génère le `pageInfo` qui sert à la pagination par curseur, mais on peut également ajouter des champs personnalisés comme ici le `totalCount` rajouté pour gérer une pagination numérotée plus traditionnelle. Les `edges` sont prévus pour contenir des informations liés au rapport entre l'objet et sa collection. Par défaut, il va contenir le curseur, qui représente le positionnement du node qu'il contient au sein de la collection. Mais il est possible de définir ses propres champs personnalisés. Les `nodes` sont tout simplement les objets de la collection. #### Intégration L'utilisation de relay apporte des données précieuses aux requêtes, mais elle requiert en conséquence plus de verbosité dans la définition des queries. Concrètement, au lieu d'une `query`, on va devoir définir 3 types : - la `query` - la `connection` - le `edge` ##### ConnectionType La définition du type de la `connection` comprend 3 éléments : - la spécification du EdgeType à utiliser - les paramètres que l'on peut appliquer à cette `connection` - les champs personnalisés qu'on peut demander en retour ```ruby class Types::Connections::ProvidersConnectionType < Types::BaseConnection # On commence par appeler la classe du EdgeType qu'on veut associer # à la connection edge_type(Types::Edges::ProviderEdge) # En définissant ici des types Input, on pourra ensuite les appeler dans # les queries associées à cette connection. # Je reviens plus bas sur les arguments eq/ne/in plus spécifiquement. class Types::ProviderApprovedInput < ::Types::BaseInputObject argument :eq, Boolean, required: false argument :ne, Boolean, required: false argument :in, [Boolean], required: false end class Types::ProviderFilterInput < ::Types::BaseInputObject argument :approved, Types::ProviderApprovedInput, required: false end # Absent par défaut, on peut intégrer un compteur du nombre d'objets # dans la collection en créant un field et son resolver # dans le ConnectionType field :total_count, Integer, null: false def total_count skipped = object.arguments[:skip] || 0 object.nodes.size + skipped end end ``` ##### EdgeType Le `edge` peut être pensé comme une table de liaison, qui fait la passerelle entre la `connection` et les `nodes` qu'elle contient. Par défaut, on doit y définir le `node_type` pour identifier le type d'objet retourné par notre `connection`. Mais il est également possible d'y définir des méthodes personnalisées. Je n'ai cependant pas encore rencontré de cas d'usage pour cette possibilité. ```ruby class Types::Edges::ProviderEdge < GraphQL::Types::Relay::BaseEdge node_type(Types::ProviderType) end ``` ##### QueryType Enfin, une fois la `connection` bien définie, il faut l'appeler: - soit dans une query spécifique - soit dans le type d'un objet parent Pour cela, au lieu d'associer le field au type de l'objet final retourné, on l'associe au type de la `connection`. ```ruby module Types class QueryType < Types::BaseObject field :providers, Types::Connections::ProvidersConnectionType, null: true def providers Provider.all end end end ``` ```ruby module Types class ParentType < Types::BaseObject # ... global_id_field :id field :display_name, String, null: true # ... field :providers, Types::Connections::ProvidersConnectionType, null: true def providers BatchLoader.for(object.id).batch(default_value: []) do |ids, loader| Provider.where(parent_id: ids).each do |rk| loader.call(rk.parent_id) { |i| i << rk } end end end end end ``` Par défaut, on peut passer tous les arguments de pagination de base à la requête (`first`, `after`, `before`, `last`...). Si nécessaire, on peut spécifier des arguments supplémentaires pour préciser notre requête : ```ruby module Types class QueryType < Types::BaseObject field :providers, Types::Connections::ProvidersConnectionType, null: true do # filter appelle les InputTypes spécifiques à ProvidersConnectionType # qu'on a définit plus haut argument :filter, Types::ProviderFilterInput, required: false end def providers Provider.all end end end ``` ##### Extraction des queries Toutes les queries qu'on veut exposer sur notre API doivent être définies dans le fichier `query_type.rb`montré juste avant. Mais avec le gain en complexité d'une API, le fichier va être vite surchargé. Alors il est évidemment possible d'extraire la logique des queries dans d'autres fichiers, des resolvers. Le fichier `query_type.rb` se présentera alors ainsi : ```ruby module Types class QueryType < Types::BaseObject # ... field :all_providers_connection, resolver: Queries::AllProvidersConnection # ... end ``` La logique de la query va se trouver dans un fichier à part : ```ruby module Queries class AllProvidersConnection < Queries::BaseQuery description 'list all providers' type Types::Connections::ProvidersConnectionType, null: false argument :filter, Types::ProviderFilterInput, required: false argument :search, String, required: false argument :skip, Int, required: false argument :order, Types::Order, required: false def resolve(**args) res = connection_with_arguments(Provider.all, args) res = apply_filter(res, args[:filter]) res end end end ``` Les méthodes personnalisées `connection_with_arguments` et `apply_filter` sont définies dans `BaseQuery`. ##### Trier et filtrer les connections `connection_with_arguments` me permet d'intégrer des arguments de tri et de pagination numérotée à mes queries. ```ruby def connection_with_arguments(res, **args) order = args[:order] || { by: :id, direction: :desc } res = res.filter(args[:search]) if args[:search] res = res.offset(args[:skip]) if args[:skip] # la spécification du nom de la table est nécessaire pour permettre # le tri lorsque la requête SQL initiale contient des jointures res = res.order( "#{res.model.table_name}.#{order[:by]} #{order[:direction]}" ) res end ``` `apply_filter` permet d'avoir une logique globale à tous les arguments `filter` de l'API. Habituellement, dans nos API REST, nous avons l'habitude d'utiliser des scopes pour permettre aux utilisateurs de filtrer les résultats d'une requête. Mais ces filtres restent assez sommaires. Dans la conception de cette première API GraphQL, en travaillant avec mon collègue développeur React et consommateur de l'API, nous avons souhaité aller un peu plus loin. GraphQL permet de choisir précisément les données qu'on désire recevoir, alors autant donner également la possibilité de les filtrer avec précision. On a donc cherché une structure existante pour formater les filtres et nous avons décidé de nous baser sur la norme proposée dans [la documentation de GatsbyJS](https://www.gatsbyjs.org/docs/graphql-reference/#skip). > ### Complete list of possible operators > > *In the playground below the list, there is an example query with a description of what the query does for each operator.* > > * `eq` : short for `equal`, must match the given data exactly > * `ne` : short for `not equal`, must be different from the given data > * `regex` : short for `regular expression`, must match the given pattern. Note that backslashes need to be escaped twice, so `/\w+/` needs to be written as `"/\\\\w+/"`. > * `glob` : short for `global`, allows to use wildcard * which acts as a placeholder for any non-empty string > * `in` : short for `in array`, must be an element of the array > * `nin` : short for `not in array`, must NOT be an element of the array > * `gt` : short for `greater than`, must be greater than given value > * `gte` : short for `greater than or equal`, must be greater than or equal to given value > * `lt` : short for `less than`, must be less than given value > * `lte` : short for `less than or equal`, must be less than or equal to given value > * `elemMatch` : short for `element match`, this indicates that the field you are filtering will return an array of elements, on which you can apply a filter using the previous operators L'intégration actuelle ressemble à ceci : ```ruby def apply_filter(scope, filters) return scope unless filters filters.keys.each do |filter_key| filters[filter_key].keys.each do |arg_key| value = filters[filter_key][arg_key] # Ici on traduit les ID globaux qu'on reçoit du consommateur de l'API # en ID classiques reconnus par Postgresql if filter_key.match?(/([a-z])_id/) value = [filters[filter_key][arg_key]].flatten.map do |id| GraphQL::Schema::UniqueWithinType.decode(id)[1].to_i end end scope = case arg_key when :eq, :in scope.where(filter_key => value) when :ne, :nin scope.where.not(filter_key => value) when :start_with scope.where("#{filter_key} ILIKE ?", "#{value}%") else scope end end end scope end ``` C'est encore un travail en cours, le code est rudimentaire, mais cela suffit pour filtrer les premières requêtes. ### Pagination Sur cette premier application, les écrans étaient encore pensés avec une pagination numérotée. J'ai donc dû intégrer des champs par défaut à toutes les `connections` : ```ruby class Types::BaseConnection < GraphQL::Types::Relay::BaseConnection field :total_count, Integer, null: false field :total_pages, Integer, null: false field :current_page, Integer, null: false def total_count return @total_count if @total_count.present? skipped = object.arguments[:skip] || 0 @total_count = object.nodes.size + skipped end def total_pages page_size = object.first if object.respond_to?(:first) page_size ||= object.max_page_size total_count / page_size + 1 end def current_page page_size = object.first if object.respond_to?(:first) page_size ||= object.max_page_size skipped = object.arguments[:skip] || 0 (skipped / page_size) + 1 end end ``` Cela fonctionne, mais c'est regrettable parce que GraphQL est vraiment pensé pour la navigation par curseur, on est donc obligé de réinventer la roue à plusieurs endroits alors qu'on a un fonctionnement clef en main et certainement plus performant à disposition. On gagnerait certainement beaucoup en performances à pousser la pagination par curseur partout où on pourrait se passer du nombre de page total, du numéro de page, du nombre d'éléments dans la collection. # Du coup, c'est bien ? Notre première fonctionnalité utilisant GraphQL va partir en production cette semaine, alors il est encore un peu tôt pour dresser un bilan complet de l'utilisation de ce langage API. Néanmoins, les avantages pour nos développeurs front-end sont immédiats, tant React et GraphQL sont pensés pour être utilisés ensemble. En revanche, si ce premier projet n'est consommé que par une application web, il faudra bientôt se poser la question des librairies GraphQL pour langages mobile, notamment sur Flutter, les tester et espérer qu'elles offrent le même confort d'utilisation. Côté back-end, j'ai apprécié de travailler sur des classes entièrement typées. Bien que cela soit quelque peu verbeux, ça impose une certaine rigueur qui, une fois le réflexe acquis, devient particulièrement confortable (moins de bugs surprises, quand ça casse on sait plus rapidement pourquoi). :::info Note: Cela m'a d'ailleurs motivé à me réintéresser à Sorbet, peut-être le sujet d'un prochain article ? ::: Forcément, on doit réapprendre toute sa façon de construire les services de son API, mais je pense que c'est pour le mieux. Sans même envisager de basculer intégralement de REST à GraphQL, connaître et tester les paradigmes de l'un et de l'autre ne peut qu'améliorer notre façon de travailler. Restera la question des performances, notamment la lutte contre les requêtes N+1... Affaire à suivre !

    Import from clipboard

    Paste your markdown or webpage here...

    Advanced permission required

    Your current role can only read. Ask the system administrator to acquire write and comment permission.

    This team is disabled

    Sorry, this team is disabled. You can't edit this note.

    This note is locked

    Sorry, only owner can edit this note.

    Reach the limit

    Sorry, you've reached the max length this note can be.
    Please reduce the content or divide it to more notes, thank you!

    Import from Gist

    Import from Snippet

    or

    Export to Snippet

    Are you sure?

    Do you really want to delete this note?
    All users will lose their connection.

    Create a note from template

    Create a note from template

    Oops...
    This template has been removed or transferred.
    Upgrade
    All
    • All
    • Team
    No template.

    Create a template

    Upgrade

    Delete template

    Do you really want to delete this template?
    Turn this template into a regular note and keep its content, versions, and comments.

    This page need refresh

    You have an incompatible client version.
    Refresh to update.
    New version available!
    See releases notes here
    Refresh to enjoy new features.
    Your user state has changed.
    Refresh to load new user state.

    Sign in

    Forgot password

    or

    By clicking below, you agree to our terms of service.

    Sign in via Facebook Sign in via Twitter Sign in via GitHub Sign in via Dropbox Sign in with Wallet
    Wallet ( )
    Connect another wallet

    New to HackMD? Sign up

    Help

    • English
    • 中文
    • Français
    • Deutsch
    • 日本語
    • Español
    • Català
    • Ελληνικά
    • Português
    • italiano
    • Türkçe
    • Русский
    • Nederlands
    • hrvatski jezik
    • język polski
    • Українська
    • हिन्दी
    • svenska
    • Esperanto
    • dansk

    Documents

    Help & Tutorial

    How to use Book mode

    Slide Example

    API Docs

    Edit in VSCode

    Install browser extension

    Contacts

    Feedback

    Discord

    Send us email

    Resources

    Releases

    Pricing

    Blog

    Policy

    Terms

    Privacy

    Cheatsheet

    Syntax Example Reference
    # Header Header 基本排版
    - Unordered List
    • Unordered List
    1. Ordered List
    1. Ordered List
    - [ ] Todo List
    • Todo List
    > Blockquote
    Blockquote
    **Bold font** Bold font
    *Italics font* Italics font
    ~~Strikethrough~~ Strikethrough
    19^th^ 19th
    H~2~O H2O
    ++Inserted text++ Inserted text
    ==Marked text== Marked text
    [link text](https:// "title") Link
    ![image alt](https:// "title") Image
    `Code` Code 在筆記中貼入程式碼
    ```javascript
    var i = 0;
    ```
    var i = 0;
    :smile: :smile: Emoji list
    {%youtube youtube_id %} Externals
    $L^aT_eX$ LaTeX
    :::info
    This is a alert area.
    :::

    This is a alert area.

    Versions and GitHub Sync
    Get Full History Access

    • Edit version name
    • Delete

    revision author avatar     named on  

    More Less

    Note content is identical to the latest version.
    Compare
      Choose a version
      No search result
      Version not found
    Sign in to link this note to GitHub
    Learn more
    This note is not linked with GitHub
     

    Feedback

    Submission failed, please try again

    Thanks for your support.

    On a scale of 0-10, how likely is it that you would recommend HackMD to your friends, family or business associates?

    Please give us some advice and help us improve HackMD.

     

    Thanks for your feedback

    Remove version name

    Do you want to remove this version name and description?

    Transfer ownership

    Transfer to
      Warning: is a public team. If you transfer note to this team, everyone on the web can find and read this note.

        Link with GitHub

        Please authorize HackMD on GitHub
        • Please sign in to GitHub and install the HackMD app on your GitHub repo.
        • HackMD links with GitHub through a GitHub App. You can choose which repo to install our App.
        Learn more  Sign in to GitHub

        Push the note to GitHub Push to GitHub Pull a file from GitHub

          Authorize again
         

        Choose which file to push to

        Select repo
        Refresh Authorize more repos
        Select branch
        Select file
        Select branch
        Choose version(s) to push
        • Save a new version and push
        • Choose from existing versions
        Include title and tags
        Available push count

        Pull from GitHub

         
        File from GitHub
        File from HackMD

        GitHub Link Settings

        File linked

        Linked by
        File path
        Last synced branch
        Available push count

        Danger Zone

        Unlink
        You will no longer receive notification when GitHub file changes after unlink.

        Syncing

        Push failed

        Push successfully